Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/user_management_UI

This commit is contained in:
yaschalew
2026-07-11 12:39:50 +03:00
18 changed files with 481 additions and 615 deletions

View File

@@ -53,15 +53,17 @@ function normalizePhoneVariants(raw: string): string[] {
const variants = new Set<string>([stripped]);
if (stripped.startsWith('+251') && digits.length === 12) {
// +251 9XXXXXXXX → 09XXXXXXXX
variants.add('0' + digits.slice(3));
// +251 9XXXXXXXX → 251 9XXXXXXXX (no +) → 09XXXXXXXX
variants.add(digits); // 251XXXXXXXXX
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
} else if (stripped.startsWith('251') && digits.length === 12) {
// 251 9XXXXXXXX → +251 9XXXXXXXX → 09XXXXXXXX
variants.add('+' + stripped);
variants.add('0' + digits.slice(3));
variants.add('+' + stripped); // +251XXXXXXXXX
variants.add('0' + digits.slice(3)); // 09XXXXXXXXX
} else if (stripped.startsWith('0') && digits.length === 10) {
// 09XXXXXXXX → +251 9XXXXXXXX
variants.add('+251' + digits.slice(1));
// 09XXXXXXXX → +251 9XXXXXXXX → 251 9XXXXXXXX (no +)
variants.add('+251' + digits.slice(1)); // +251XXXXXXXXX
variants.add('251' + digits.slice(1)); // 251XXXXXXXXX
} else if (!stripped.startsWith('+') && digits.length >= 9) {
// bare international digits without +
variants.add('+' + digits);
@@ -183,15 +185,81 @@ export class BookingsService {
const { status, page = 1, pageSize = 20 } = filters;
const skip = (page - 1) * pageSize;
// Authenticated-user bookings don't store contactPhone — their phone lives in
// iam.users.phone_number linked via passenger.iamUserId. Mirror the same lookup
// that findAll uses for the search field.
const iamRows = await this.dataSource
.query<{ id: string }[]>(
`SELECT u.id FROM iam.users u WHERE u.phone_number = ANY($1::text[])`,
[variants],
)
.catch((err: unknown) => {
this.logger.warn(`IAM phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { id: string }[];
});
const iamPassengerIds = iamRows.length > 0
? (await this.prisma.passenger.findMany({
where: { iamUserId: { in: iamRows.map(r => r.id) } },
select: { id: true },
})).map(p => p.id)
: [];
// Guest bookings store phone in TravelerProfile.notes JSON (created for every guest booking).
// This catches cases where contactPhone was null but the phone was still recorded in the profile.
const travelerRows = await this.dataSource
.query<{ passengerId: string }[]>(
`SELECT DISTINCT passenger_id AS "passengerId"
FROM passenger.traveler_profiles
WHERE notes IS NOT NULL
AND (notes::jsonb->>'phone') = ANY($1::text[])`,
[variants],
)
.catch((err: unknown) => {
this.logger.warn(`TravelerProfile phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { passengerId: string }[];
});
const travelerPassengerIds = travelerRows.map(r => r.passengerId);
// Guests who saved their profile (savePassengerDetails:true) have a SavedPassengerProfile
// row with phone + deviceId. Guest bookings store the deviceId in Booking.userAgent.
const savedProfileRows = await this.dataSource
.query<{ deviceId: string }[]>(
`SELECT DISTINCT device_id AS "deviceId"
FROM passenger.saved_passenger_profiles
WHERE phone = ANY($1::text[]) AND device_id IS NOT NULL`,
[variants],
)
.catch((err: unknown) => {
this.logger.warn(`SavedPassengerProfile phone lookup failed: ${err instanceof Error ? err.message : String(err)}`);
return [] as { deviceId: string }[];
});
const guestDeviceIds = savedProfileRows.map(r => r.deviceId);
// Merge all passenger IDs from every source
const allPassengerIds = [...new Set([...iamPassengerIds, ...travelerPassengerIds])];
const where: any = {
OR: [
{ contactPhone: { in: variants } },
{ passenger: { user: { phone: { in: variants } } } },
...(allPassengerIds.length > 0 ? [{ passengerId: { in: allPassengerIds } }] : []),
...(guestDeviceIds.length > 0 ? [{ userAgent: { in: guestDeviceIds } }] : []),
],
};
if (status) where.status = status;
const [items, total] = await Promise.all([
// PackageBooking is a separate table with its own contactPhone field —
// must be queried independently or guest package bookings are invisible.
const pkgWhere: any = {
OR: [
{ contactPhone: { in: variants } },
...(allPassengerIds.length > 0 ? [{ passengerId: { in: allPassengerIds } }] : []),
],
};
if (status) pkgWhere.status = status;
const [items, total, pkgItems, pkgTotal] = await Promise.all([
this.prisma.booking.findMany({
where,
skip,
@@ -205,37 +273,84 @@ export class BookingsService {
},
}),
this.prisma.booking.count({ where }),
this.prisma.packageBooking.findMany({
where: pkgWhere,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
include: {
package: {
include: {
outboundSchedule: { include: { originStation: true, destinationStation: true } },
},
},
paymentIntent: { select: { method: true, status: true, amountMinor: true, currency: true } },
},
}),
this.prisma.packageBooking.count({ where: pkgWhere }),
]);
const mappedBookings = items.map(booking => ({
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: 'ETB',
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
arrivalAt: booking.schedule.arrivalAt,
},
payment: booking.paymentIntent ?? undefined,
seatCount: booking.seats.length,
}));
const mappedPkg = pkgItems.map((b: any) => ({
id: b.id,
bookingRef: b.bookingRef,
status: b.status,
totalMinor: b.totalMinor,
currency: b.currency || 'ETB',
displayCurrency: b.displayCurrency ?? null,
displayTotalMinor: b.displayTotalMinor ?? null,
adultCount: b.adultCount,
childCount: b.childCount,
bookingType: 'PACKAGE',
returnLegStatus: null,
createdAt: b.createdAt,
schedule: b.package?.outboundSchedule
? {
train: null,
originStation: b.package.outboundSchedule.originStation,
destinationStation: b.package.outboundSchedule.destinationStation,
departureAt: b.package.outboundSchedule.departureAt,
arrivalAt: b.package.outboundSchedule.arrivalAt,
}
: null,
payment: b.paymentIntent ?? undefined,
seatCount: b.passengerCount,
}));
const allItems = [...mappedBookings, ...mappedPkg]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, pageSize);
return {
items: items.map(booking => ({
id: booking.id,
bookingRef: booking.bookingRef,
status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount),
currency: 'ETB',
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
adultCount: booking.adultCount,
childCount: booking.childCount,
bookingType: booking.bookingType,
returnLegStatus: (booking as any).returnLegStatus ?? null,
createdAt: booking.createdAt,
schedule: {
train: booking.schedule.train,
originStation: booking.schedule.originStation,
destinationStation: booking.schedule.destinationStation,
departureAt: booking.schedule.departureAt,
arrivalAt: booking.schedule.arrivalAt,
},
payment: booking.paymentIntent ?? undefined,
seatCount: booking.seats.length,
})),
items: allItems,
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
total: total + pkgTotal,
totalPages: Math.ceil((total + pkgTotal) / pageSize),
},
};
}
@@ -520,14 +635,47 @@ export class BookingsService {
? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>(
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
[iamUserIds],
)
).catch(() => [] as { id: string; email: string; name: any; phone_number: string | null }[])
: [];
const iamMap = new Map(iamRows.map(r => [r.id, r]));
// For bookings that have no contactEmail/contactPhone and no IAM match,
// fall back to TravelerProfile.notes JSON.
// Covers: (1) legacy authenticated bookings where IAM returns nothing,
// (2) guest bookings where passenger.iamUserId is null.
const passengerIdsNeedingFallback = regularItems
.filter((b: any) => !b.contactEmail && !b.contactPhone && (!b.passenger?.iamUserId || !iamMap.has(b.passenger.iamUserId)))
.map((b: any) => b.passengerId)
.filter(Boolean) as string[];
const travelerProfileMap = new Map<string, { phone: string | null; email: string | null }>();
if (passengerIdsNeedingFallback.length > 0) {
const profiles = await this.prisma.travelerProfile.findMany({
where: { passengerId: { in: passengerIdsNeedingFallback } },
select: { passengerId: true, notes: true },
orderBy: { createdAt: 'asc' },
});
for (const profile of profiles) {
if (travelerProfileMap.has(profile.passengerId)) continue;
try {
const notes = profile.notes ? (typeof profile.notes === 'string' ? JSON.parse(profile.notes) : profile.notes) : null;
if (notes?.phone || notes?.email) {
travelerProfileMap.set(profile.passengerId, { phone: notes.phone ?? null, email: notes.email ?? null });
}
} catch { /* ignore */ }
}
}
const mappedRegular = regularItems.map((booking: any) => {
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory }));
const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values());
// Resolve contact: DB row → IAM → TravelerProfile notes → seat name fallback
const fallback = booking.passengerId ? travelerProfileMap.get(booking.passengerId) : null;
const resolvedEmail = booking.contactEmail ?? iam?.email ?? fallback?.email ?? null;
const resolvedPhone = booking.contactPhone ?? iam?.phone_number ?? fallback?.phone ?? null;
return {
id: booking.id,
bookingRef: booking.bookingRef,
@@ -536,8 +684,8 @@ export class BookingsService {
currency: 'ETB',
displayCurrency: booking.displayCurrency,
displayTotalMinor: booking.displayTotalMinor,
contactEmail: booking.contactEmail,
contactPhone: booking.contactPhone,
contactEmail: resolvedEmail,
contactPhone: resolvedPhone,
bookingType: booking.bookingType,
packageId: booking.packageId ?? null,
priceTierId: (booking as any).priceTierId ?? null,
@@ -625,6 +773,18 @@ export class BookingsService {
}
}
/** Resolves contactEmail/contactPhone for an IAM-authenticated passenger booking. */
private async resolveIamContact(passengerId?: string): Promise<{ contactEmail: string | null; contactPhone: string | null }> {
if (!passengerId) return { contactEmail: null, contactPhone: null };
const passenger = await this.prisma.passenger.findUnique({ where: { id: passengerId }, select: { iamUserId: true } });
if (!passenger?.iamUserId) return { contactEmail: null, contactPhone: null };
const rows = await this.dataSource.query<{ email: string; phone_number: string | null }[]>(
`SELECT email, phone_number FROM iam.users WHERE id = $1 LIMIT 1`,
[passenger.iamUserId],
);
return { contactEmail: rows[0]?.email ?? null, contactPhone: rows[0]?.phone_number ?? null };
}
private async createOneWayBooking(dto: CreateBookingDto) {
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
@@ -642,7 +802,10 @@ export class BookingsService {
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
const passengersData = await this.processPassengers(dto.passengers as any[]);
const [passengersData, iamContact] = await Promise.all([
this.processPassengers(dto.passengers as any[]),
this.resolveIamContact(dto.passengerId),
]);
const { adultCount, childCount } = this.countPassengers(passengersData);
const fareCalculation = dto.packageId && dto.priceTierId
? await this.calculatePackageFare(dto.priceTierId, adultCount, childCount)
@@ -699,6 +862,8 @@ export class BookingsService {
childCount,
displayCurrency,
displayTotalMinor,
contactEmail: iamContact.contactEmail,
contactPhone: iamContact.contactPhone,
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
seats: {
create: passengersWithFares.map(p => ({
@@ -770,7 +935,10 @@ export class BookingsService {
throw new NotFoundException('Origin or destination stops not found');
}
const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]);
const [passengersData, iamContact] = await Promise.all([
this.processRoundTripPassengers(dto.passengers as any[]),
this.resolveIamContact(dto.passengerId),
]);
const { adultCount, childCount } = this.countPassengers(passengersData);
// Package bookings use fixed tier price split equally across both legs
@@ -877,6 +1045,8 @@ export class BookingsService {
returnHoldId: dto.returnHoldId,
returnSeatClassId: dto.returnSeatClassId,
returnLegStatus: 'NEITHER_USED',
contactEmail: iamContact.contactEmail,
contactPhone: iamContact.contactPhone,
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
seats: {
create: [
@@ -986,7 +1156,10 @@ export class BookingsService {
if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule');
if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination station not found on leg-2 schedule');
const passengersData = await this.processPassengers(dto.passengers as any[]);
const [passengersData, iamContact] = await Promise.all([
this.processPassengers(dto.passengers as any[]),
this.resolveIamContact(dto.passengerId),
]);
const { adultCount, childCount } = this.countPassengers(passengersData);
const leg2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId;
@@ -1061,6 +1234,8 @@ export class BookingsService {
leg2OriginStationId: dto.transitStationId,
leg2DestinationStationId: dto.leg2DestinationStationId,
leg2SeatClassId,
contactEmail: iamContact.contactEmail,
contactPhone: iamContact.contactPhone,
seats: {
create: [
...passengersWithFares.map(p => ({
@@ -1175,7 +1350,10 @@ export class BookingsService {
if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit station not found');
if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination not found');
const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]);
const [passengersData, iamContact] = await Promise.all([
this.processRoundTripPassengers(dto.passengers as any[]),
this.resolveIamContact(dto.passengerId),
]);
const { adultCount, childCount } = this.countPassengers(passengersData);
const nat = passengersData[0]?.nationality;
@@ -1273,6 +1451,8 @@ export class BookingsService {
returnLeg2DestStationId: dto.returnLeg2DestinationStationId,
returnLeg2SeatClassId: retL2SeatClassId,
returnLegStatus: 'NEITHER_USED',
contactEmail: iamContact.contactEmail,
contactPhone: iamContact.contactPhone,
seats: {
create: [
// Outbound leg-1 (sequence 1)

View File

@@ -53,6 +53,23 @@ export class GuestBookingService {
) {}
async createGuestBooking(dto: CreateGuestBookingDto, req?: any) {
// Enrich passengers with phone/email from SavedPassengerProfile when not supplied inline.
// The portal calls /passengers/save-details before booking but doesn't re-send contact
// fields in the booking payload, so we pull them from the saved profile by deviceId.
if (dto.deviceId && dto.passengers?.length) {
const saved = await this.prisma.savedPassengerProfile.findMany({
where: { deviceId: dto.deviceId },
orderBy: { createdAt: 'desc' },
select: { passengerName: true, phone: true, email: true },
});
if (saved.length) {
dto.passengers = dto.passengers.map(p => {
if (p.phone && p.email) return p;
const match = saved.find(s => s.passengerName?.toLowerCase() === p.passengerName?.toLowerCase());
return { ...p, phone: p.phone || match?.phone || undefined, email: p.email || match?.email || undefined };
});
}
}
if (dto.bookingType === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto, req);
if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto, req);
if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createGuestRoundTripTransitBooking(dto, req);

View File

@@ -105,6 +105,13 @@ export class CurrenciesService {
};
}
async syncExchangeRates() {
// Placeholder: in production this would fetch from an external FX API.
// For now, return the current rates as-is.
const currencies = await this.getAllCurrencies();
return { synced: true, rates: currencies };
}
async deleteCurrency(id: string) {
const existing = await this.prisma.currencyExchangeRate.findUnique({
where: { id },
@@ -122,15 +129,6 @@ export class CurrenciesService {
return { message: 'Currency deleted successfully' };
}
async syncExchangeRates() {
await this.currencyService.syncExchangeRates();
const rates = await this.prisma.currencyExchangeRate.findMany({
orderBy: { effectiveDate: 'desc' },
take: 10,
});
return { message: 'Exchange rates synced successfully', synced: rates.length };
}
private getCurrencyName(code: string): string {
const names: Record<string, string> = {
ETB: 'Ethiopian Birr',

View File

@@ -0,0 +1,55 @@
import { Controller, Get, Post, Patch, Delete, Param, Body, SetMetadata } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { IsEnum, IsNumber, IsOptional, IsString, Min } from 'class-validator';
import { Currency } from '@prisma/client';
import { CurrencyService } from './currency.service';
import { PassengerAdmin } from '../../common/passenger-guards';
class CreateRateDto {
@IsEnum(Currency) fromCurrency: Currency;
@IsEnum(Currency) toCurrency: Currency;
@IsNumber() @Min(0.000001) rate: number;
@IsOptional() @IsString() source?: string;
}
class UpdateRateDto {
@IsNumber() @Min(0.000001) rate: number;
@IsOptional() @IsString() source?: string;
}
@ApiTags('Currency')
@Controller('currencies')
export class CurrencyController {
constructor(private readonly currencyService: CurrencyService) {}
@Get()
@SetMetadata('isPublic', true)
@ApiOperation({ summary: 'List all exchange rates' })
listRates() {
return this.currencyService.listRates();
}
@Post()
@PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create exchange rate' })
create(@Body() dto: CreateRateDto) {
return this.currencyService.upsertRate(dto.fromCurrency, dto.toCurrency, dto.rate, undefined, dto.source ?? 'MANUAL');
}
@Patch(':id')
@PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update exchange rate by ID' })
update(@Param('id') id: string, @Body() dto: UpdateRateDto) {
return this.currencyService.updateRateById(id, dto.rate, dto.source ?? 'MANUAL');
}
@Delete(':id')
@PassengerAdmin()
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Delete exchange rate by ID' })
delete(@Param('id') id: string) {
return this.currencyService.deleteRate(id);
}
}

View File

@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { CurrencyService } from './currency.service';
import { CurrencyController } from './currency.controller';
import { PrismaModule } from '../../common/prisma.module';
@Module({
imports: [PrismaModule, HttpModule],
imports: [PrismaModule],
controllers: [CurrencyController],
providers: [CurrencyService],
exports: [CurrencyService],
})

View File

@@ -1,12 +1,4 @@
import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../common/prisma.service';
import { Currency } from '@prisma/client';
@@ -24,11 +16,7 @@ const CHARGE_CURRENCY_DECIMALS: Record<string, number> = {
export class CurrencyService {
private readonly logger = new Logger(CurrencyService.name);
constructor(
private readonly prisma: PrismaService,
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {}
constructor(private readonly prisma: PrismaService) {}
/**
* Converts a stored display-currency minor amount to the charge major amount
@@ -148,53 +136,6 @@ export class CurrencyService {
return Number(exchangeRate.rate);
}
async syncExchangeRates(): Promise<void> {
this.logger.log('Syncing exchange rates from central bank API');
const today = this.todayUtc();
// Fallback rates used when the API is unreachable
const fallbackRates = [
{ from: Currency.ETB, to: Currency.ETB, rate: 1.0 },
{ from: Currency.ETB, to: Currency.DJF, rate: 3.25 },
{ from: Currency.ETB, to: Currency.USD, rate: 0.018 },
{ from: Currency.DJF, to: Currency.ETB, rate: 0.3077 },
{ from: Currency.USD, to: Currency.ETB, rate: 55.56 },
];
const apiUrl = this.configService.get<string>('EXCHANGE_RATE_API_URL');
if (apiUrl) {
try {
const response = await firstValueFrom(
this.httpService.get<Record<string, number>>(apiUrl, { timeout: 5000 }),
);
// Expected response shape: { "ETB_DJF": 3.25, "ETB_USD": 0.018, ... }
const data = response.data;
const apiRates = [
{ from: Currency.ETB, to: Currency.ETB, rate: 1.0 },
{ from: Currency.ETB, to: Currency.DJF, rate: data['ETB_DJF'] ?? fallbackRates[1].rate },
{ from: Currency.ETB, to: Currency.USD, rate: data['ETB_USD'] ?? fallbackRates[2].rate },
{ from: Currency.DJF, to: Currency.ETB, rate: data['DJF_ETB'] ?? fallbackRates[3].rate },
{ from: Currency.USD, to: Currency.ETB, rate: data['USD_ETB'] ?? fallbackRates[4].rate },
];
for (const { from, to, rate } of apiRates) {
await this.upsertRate(from, to, rate, today, 'CENTRAL_BANK_API');
}
this.logger.log('Exchange rates synced from central bank API');
return;
} catch (err) {
this.logger.warn(
`Central bank API unreachable (${(err as Error).message}), falling back to configured rates`,
);
}
}
// Fallback: persist the static rates so the DB always has a current row
for (const { from, to, rate } of fallbackRates) {
await this.upsertRate(from, to, rate, today, 'FALLBACK');
}
this.logger.log('Exchange rates synced using fallback values');
}
async listRates() {
return this.prisma.currencyExchangeRate.findMany({
orderBy: [{ fromCurrency: 'asc' }, { toCurrency: 'asc' }, { effectiveDate: 'desc' }],

View File

@@ -49,9 +49,4 @@ export class CurrencyController {
return this.currency.deleteRate(id);
}
@Post('sync')
@ApiOperation({ summary: 'Trigger exchange rate sync from external provider' })
sync() {
return this.currency.syncExchangeRates();
}
}

View File

@@ -39,10 +39,18 @@ export class PassengersService {
const where: any = {};
if (search) {
// IAM user search: resolve matching iamUserIds first, then filter by passengerId
const iamRows = await this.dataSource.query<{ id: string }[]>(
`SELECT id FROM iam.users WHERE (name->>'en') ILIKE $1 OR (name->>'am') ILIKE $1 OR email ILIKE $1 OR phone_number ILIKE $1`,
[`%${search}%`],
);
const matchedPassengers = iamRows.length > 0
? await this.prisma.passenger.findMany({ where: { iamUserId: { in: iamRows.map(r => r.id) } }, select: { id: true } })
: [];
where.OR = [
{ fullName: { contains: search, mode: 'insensitive' } },
{ passenger: { user: { email: { contains: search, mode: 'insensitive' } } } },
{ passenger: { user: { phone: { contains: search, mode: 'insensitive' } } } },
...(matchedPassengers.length > 0 ? [{ passengerId: { in: matchedPassengers.map(p => p.id) } }] : []),
];
}
@@ -66,7 +74,6 @@ export class PassengersService {
include: {
passenger: {
include: {
user: true,
loyalty: true,
wallet: true,
_count: { select: { bookings: true } },
@@ -106,17 +113,13 @@ export class PassengersService {
return {
items: items.map(profile => {
const passenger = profile.passenger;
const localUser = (passenger as any)?.user ?? null;
const iam = passenger?.iamUserId ? iamMap.get(passenger.iamUserId) : undefined;
const faydaVerified = localUser?.faydaVerified === true
|| iam?.metadata?.faydaVerified === true
const faydaVerified = iam?.metadata?.faydaVerified === true
|| iam?.metadata?.faydaVerified === 'true';
// Get additional data from bookings for guest passengers
const guestBooking = (passenger as any)?.bookings?.[0] ?? null;
const guestSeat = guestBooking?.seats?.[0] ?? null;
// Parse notes JSON to extract phone and other data
let notesData: any = null;
if (profile.notes) {
try {
@@ -129,25 +132,23 @@ export class PassengersService {
return {
id: profile.id,
fullName: profile.fullName,
email: localUser?.email ?? iam?.email ?? notesData?.email ?? guestBooking?.contactEmail ?? null,
phone: localUser?.phone ?? iam?.phone_number ?? notesData?.phone ?? guestBooking?.contactPhone ?? null,
gender: profile.gender ?? localUser?.gender ?? iam?.metadata?.gender ?? null,
email: iam?.email ?? notesData?.email ?? guestBooking?.contactEmail ?? null,
phone: iam?.phone_number ?? notesData?.phone ?? guestBooking?.contactPhone ?? null,
gender: profile.gender ?? iam?.metadata?.gender ?? null,
dateOfBirth: profile.dateOfBirth
? new Date(profile.dateOfBirth).toISOString().split('T')[0]
: (localUser?.dateOfBirth
? (localUser.dateOfBirth instanceof Date ? localUser.dateOfBirth.toISOString().split('T')[0] : localUser.dateOfBirth)
: iam?.metadata?.dateOfBirth ?? null),
nationality: localUser?.nationality ?? iam?.metadata?.nationality ?? notesData?.nationality ?? (guestSeat?.passportCountry ? (guestSeat.passportCountry === 'Ethiopia' ? 'Ethiopian' : guestSeat.passportCountry) : null),
nationalityCode: localUser?.nationalityCode ?? iam?.metadata?.nationalityCode ?? null,
: (iam?.metadata?.dateOfBirth ?? null),
nationality: iam?.metadata?.nationality ?? notesData?.nationality ?? (guestSeat?.passportCountry ? (guestSeat.passportCountry === 'Ethiopia' ? 'Ethiopian' : guestSeat.passportCountry) : null),
nationalityCode: iam?.metadata?.nationalityCode ?? null,
faydaVerified,
faydaVerifiedAt: localUser?.faydaVerifiedAt ?? iam?.metadata?.faydaVerifiedAt ?? null,
passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? notesData?.passportNumber ?? guestSeat?.passportNumber ?? null,
passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? notesData?.passportCountry ?? guestSeat?.passportCountry ?? null,
passportExpiryDate: localUser?.passportExpiryDate ?? iam?.metadata?.passportExpiryDate ?? null,
faydaVerifiedAt: iam?.metadata?.faydaVerifiedAt ?? null,
passportNumber: iam?.metadata?.passportNumber ?? notesData?.passportNumber ?? guestSeat?.passportNumber ?? null,
passportCountry: iam?.metadata?.passportCountry ?? notesData?.passportCountry ?? guestSeat?.passportCountry ?? null,
passportExpiryDate: iam?.metadata?.passportExpiryDate ?? null,
idDocumentType: profile.nationalId ? 'NATIONAL_ID' : (notesData?.idDocumentType ?? null),
verified: faydaVerified,
lastLoginAt: localUser?.lastLoginAt ?? null,
role: localUser?.role ?? null,
lastLoginAt: null,
role: null,
loyalty: passenger?.loyalty
? { tier: passenger.loyalty.tier, pointsBalance: passenger.loyalty.pointsBalance, lifetimePoints: (passenger.loyalty as any).lifetimePoints ?? 0 }
: null,
@@ -279,6 +280,8 @@ export class PassengersService {
passengerName: p.passengerName,
dateOfBirth: p.dateOfBirth,
nationality: p.nationality,
phone: p.phone ?? null,
email: p.email ?? null,
})),
message: 'Passenger details saved successfully',
};
@@ -434,18 +437,12 @@ export class PassengersService {
async deletePassenger(id: string, cascade = false) {
// id may be a TravelerProfile.id (from the list endpoint) or a Passenger.id
let passenger = await this.prisma.passenger.findUnique({
where: { id },
include: { user: true },
});
let passenger = await this.prisma.passenger.findUnique({ where: { id } });
if (!passenger) {
const profile = await this.prisma.travelerProfile.findUnique({ where: { id } });
if (!profile?.passengerId) throw new NotFoundException('Passenger not found');
passenger = await this.prisma.passenger.findUnique({
where: { id: profile.passengerId },
include: { user: true },
});
passenger = await this.prisma.passenger.findUnique({ where: { id: profile.passengerId } });
if (!passenger) throw new NotFoundException('Passenger not found');
}
@@ -454,7 +451,7 @@ export class PassengersService {
if (!cascade) {
const usage = await this.checkPassengerUsage(passengerId);
if (usage.isInUse && usage.constraints) {
const passengerName = (passenger as any).user?.fullName || `Passenger ${passengerId.slice(-8)}`;
const passengerName = `Passenger ${passengerId.slice(-8)}`;
throw new DeleteOperationException('Passenger', passengerName, usage.constraints);
}
}

View File

@@ -253,18 +253,6 @@ export class TasksService {
}
}
// ─────────────────────────────────────────────────────────────────────────
// Daily at 01:00 EAT: fetch mid-market rates from central bank API.
// ─────────────────────────────────────────────────────────────────────────
@Cron('0 1 * * *', { timeZone: 'Africa/Addis_Ababa' })
async syncExchangeRates() {
try {
await this.currencyService.syncExchangeRates();
} catch (err) {
this.logger.error(`Exchange rate sync failed: ${(err as Error).message}`);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Daily at 02:00 EAT: purge expired/stale records to enforce data retention.
// ─────────────────────────────────────────────────────────────────────────

View File

@@ -255,8 +255,8 @@ function BookingsPageContent() {
{
key: 'contact', label: 'Primary contact',
render: (booking: any) => {
const phone = booking.contactPhone || booking.passenger?.phone || '—';
const email = booking.contactEmail || booking.passenger?.email || '—';
const phone = booking.contactPhone || booking.passenger?.phone || booking.seats?.[0]?.phone || '—';
const email = booking.contactEmail || booking.passenger?.email || booking.seats?.[0]?.email || '—';
return (
<div>
<div className="font-medium">{phone}</div>
@@ -403,9 +403,9 @@ function BookingsPageContent() {
<section>
<SectionHeader title="Passenger" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Full Name" value={b.passenger?.fullName || b.contactEmail} />
<Field label="Email" value={b.contactEmail || b.passenger?.email} />
<Field label="Phone" value={b.contactPhone || b.passenger?.phone} />
<Field label="Full Name" value={b.passenger?.fullName || b.seats?.[0]?.passengerName || b.passengerNames?.[0] || '—'} />
<Field label="Email" value={b.contactEmail || b.passenger?.email || '—'} />
<Field label="Phone" value={b.contactPhone || b.passenger?.phone || '—'} />
<Field label="Passenger ID" value={b.passengerId} mono truncate />
</div>
</section>

View File

@@ -197,12 +197,7 @@ export default function ClassesPage() {
<h1 className="text-2xl font-bold text-foreground">Classes</h1>
<p className="text-muted-foreground">Manage class configurations with pricing by coach type</p>
</div>
<ActionButton
icon={Plus}
onClick={() => handleOpenModal()}
>
Add Class
</ActionButton>
</div>
<div className="card">

View File

@@ -2,55 +2,43 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Edit, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { Edit, Loader2, Plus, Trash2 } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient } from '@/lib/api-client';
interface CurrencyRate {
interface ExchangeRate {
id: string;
code: string;
name: string;
symbol: string;
baseCurrencyCode: string;
exchangeRate: number;
isActive: boolean;
createdAt: string;
fromCurrency: string;
toCurrency: string;
rate: number;
source: string;
effectiveDate: 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: '$' },
const CURRENCY_META: Record<string, { name: string }> = {
ETB: { name: 'Ethiopian Birr' },
DJF: { name: 'Djiboutian Franc' },
USD: { name: 'US Dollar' },
};
const CURRENCY_OPTIONS = ['ETB', 'DJF', 'USD'];
export default function CurrenciesPage() {
const [editingRate, setEditingRate] = useState<CurrencyRate | null>(null);
const [rateInput, setRateInput] = useState('');
const [error, setError] = useState<string | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [addForm, setAddForm] = useState({ code: '', name: '', symbol: '', exchangeRate: '' });
const [deleteConfirm, setDeleteConfirm] = useState<CurrencyRate | null>(null);
const [editingRate, setEditingRate] = useState<ExchangeRate | null>(null);
const [rateInput, setRateInput] = useState('');
const [error, setError] = useState<string | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [addForm, setAddForm] = useState({ fromCurrency: 'ETB', toCurrency: 'DJF', rate: '' });
const [deleteConfirm, setDeleteConfirm] = useState<ExchangeRate | null>(null);
const queryClient = useQueryClient();
const { data: currencies = [], isLoading } = useQuery<CurrencyRate[]>({
const { data: rates = [], isLoading } = useQuery<ExchangeRate[]>({
queryKey: ['currencies'],
queryFn: () => apiClient.get('/currencies'),
});
const updateMutation = useMutation({
mutationFn: ({ id, exchangeRate }: { id: string; exchangeRate: number }) =>
apiClient.patch(`/currencies/${id}`, { exchangeRate }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
setEditingRate(null);
setError(null);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to update exchange rate');
},
select: (d: any) => (Array.isArray(d) ? d : d?.data ?? d?.items ?? []),
});
const createMutation = useMutation({
@@ -58,10 +46,21 @@ export default function CurrenciesPage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
setShowAddModal(false);
setAddForm({ code: '', name: '', symbol: '', exchangeRate: '' });
setAddForm({ fromCurrency: 'ETB', toCurrency: 'DJF', rate: '' });
setError(null);
},
onError: (err: any) => setError(err.response?.data?.message || 'Failed to add currency'),
onError: (err: any) => setError(err.response?.data?.message || 'Failed to create rate'),
});
const updateMutation = useMutation({
mutationFn: ({ id, rate }: { id: string; rate: number }) =>
apiClient.patch(`/currencies/${id}`, { rate }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
setEditingRate(null);
setError(null);
},
onError: (err: any) => setError(err.response?.data?.message || 'Failed to update rate'),
});
const deleteMutation = useMutation({
@@ -70,88 +69,70 @@ export default function CurrenciesPage() {
queryClient.invalidateQueries({ queryKey: ['currencies'] });
setDeleteConfirm(null);
},
onError: (err: any) => setError(err.response?.data?.message || 'Failed to delete currency'),
onError: (err: any) => setError(err.response?.data?.message || 'Failed to delete rate'),
});
const syncMutation = useMutation({
mutationFn: () => apiClient.post('/currencies/sync-rates', {}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['currencies'] }),
onError: (err: any) => setError(err.response?.data?.message || 'Failed to sync rates'),
});
const handleEdit = (currency: CurrencyRate) => {
setEditingRate(currency);
setRateInput(currency.exchangeRate.toString());
setError(null);
};
const handleSave = async () => {
const rate = parseFloat(rateInput);
if (isNaN(rate) || rate <= 0) {
setError('Exchange rate must be a positive number');
return;
}
await updateMutation.mutateAsync({ id: editingRate!.id, exchangeRate: rate });
};
const currenciesArray = Array.isArray(currencies) ? currencies : (currencies as any)?.items ?? [];
const columns = [
{
key: 'code',
label: 'Currency',
render: (c: CurrencyRate) => (
<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}
key: 'pair',
label: 'Pair',
render: (r: ExchangeRate) => (
<div className="flex items-center gap-2">
<span className="font-mono font-semibold">{r.fromCurrency}</span>
<span className="text-muted-foreground"></span>
<span className="font-mono font-semibold">{r.toCurrency}</span>
<span className="text-xs text-muted-foreground ml-1">
{CURRENCY_META[r.toCurrency]?.name ?? r.toCurrency}
</span>
<div>
<div className="font-semibold">{c.code}</div>
<div className="text-xs text-muted-foreground">{CURRENCY_META[c.code]?.name ?? c.name}</div>
</div>
</div>
),
},
{
key: 'baseCurrencyCode',
label: 'Base',
render: (c: CurrencyRate) => (
<span className="font-mono text-sm text-muted-foreground">{c.baseCurrencyCode}</span>
),
},
{
key: 'exchangeRate',
label: 'Exchange Rate',
render: (c: CurrencyRate) => (
key: 'rate',
label: 'Rate',
render: (r: ExchangeRate) => (
<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}
1 {r.fromCurrency} = {r.rate} {r.toCurrency}
</div>
{r.rate > 0 && (
<div className="text-xs text-muted-foreground">
1 {r.toCurrency} = {(1 / r.rate).toFixed(6)} {r.fromCurrency}
</div>
)}
</div>
),
},
{
key: 'createdAt',
label: 'Last Updated',
render: (c: CurrencyRate) => (
key: 'source',
label: 'Source',
render: (r: ExchangeRate) => (
<span className="text-sm text-muted-foreground">{r.source ?? '—'}</span>
),
},
{
key: 'effectiveDate',
label: 'Effective Date',
render: (r: ExchangeRate) => (
<span className="text-sm text-muted-foreground">
{new Date(c.createdAt).toLocaleDateString()}
{r.effectiveDate ? new Date(r.effectiveDate).toLocaleDateString() : '—'}
</span>
),
},
];
const actions = [
{ label: 'Edit', onClick: handleEdit, variant: 'secondary' as const, icon: Edit },
{
label: 'Edit',
onClick: (r: ExchangeRate) => { setEditingRate(r); setRateInput(String(r.rate)); setError(null); },
variant: 'secondary' as const,
icon: Edit,
},
{
label: 'Delete',
onClick: (c: CurrencyRate) => setDeleteConfirm(c),
onClick: (r: ExchangeRate) => setDeleteConfirm(r),
variant: 'danger' as const,
icon: Trash2,
show: (c: CurrencyRate) => c.id !== 'etb-base',
},
];
@@ -160,64 +141,27 @@ export default function CurrenciesPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Exchange Rates</h1>
<p className="text-muted-foreground mt-1">
Manage ETB exchange rates for display currencies (DJF, USD)
</p>
</div>
<div className="flex gap-2">
<ActionButton icon={Plus} onClick={() => { setError(null); setShowAddModal(true); }}>Add Currency</ActionButton>
<ActionButton
icon={RefreshCw}
variant="secondary"
onClick={() => syncMutation.mutate()}
loading={syncMutation.isPending}
>
Sync Rates
</ActionButton>
<p className="text-muted-foreground mt-1">Manage currency exchange rates</p>
</div>
<ActionButton icon={Plus} onClick={() => { setError(null); setShowAddModal(true); }}>
Add Rate
</ActionButton>
</div>
{error && !editingRate && (
{error && !editingRate && !showAddModal && (
<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="grid grid-cols-3 gap-4 mb-6">
{(['ETB', 'DJF', 'USD'] as const).map((code) => {
const entry = currenciesArray.find((c: CurrencyRate) => c.code === code);
return (
<div
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>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : (
<DataTable
data={currenciesArray}
data={rates}
columns={columns}
actions={actions}
loading={false}
@@ -226,117 +170,85 @@ export default function CurrenciesPage() {
)}
</div>
<Modal
isOpen={showAddModal}
onClose={() => { setShowAddModal(false); setError(null); }}
title="Add Currency"
size="sm"
>
{/* Add Modal */}
<Modal isOpen={showAddModal} onClose={() => { setShowAddModal(false); setError(null); }} title="Add Exchange Rate" size="sm">
<div className="space-y-4">
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">{error}</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Code *</label>
<input className="input uppercase" placeholder="e.g., EUR" maxLength={5}
value={addForm.code} onChange={(e) => setAddForm({ ...addForm, code: e.target.value.toUpperCase() })} />
<label className="label">From *</label>
<select className="input" value={addForm.fromCurrency} onChange={(e) => setAddForm({ ...addForm, fromCurrency: e.target.value })}>
{CURRENCY_OPTIONS.map(c => <option key={c} value={c}>{c} {CURRENCY_META[c]?.name ?? c}</option>)}
</select>
</div>
<div>
<label className="label">Symbol *</label>
<input className="input" placeholder="e.g., €"
value={addForm.symbol} onChange={(e) => setAddForm({ ...addForm, symbol: e.target.value })} />
<label className="label">To *</label>
<select className="input" value={addForm.toCurrency} onChange={(e) => setAddForm({ ...addForm, toCurrency: e.target.value })}>
{CURRENCY_OPTIONS.map(c => <option key={c} value={c}>{c} {CURRENCY_META[c]?.name ?? c}</option>)}
</select>
</div>
</div>
<div>
<label className="label">Name *</label>
<input className="input" placeholder="e.g., Euro"
value={addForm.name} onChange={(e) => setAddForm({ ...addForm, name: e.target.value })} />
</div>
<div>
<label className="label">Exchange Rate (1 ETB = ? {addForm.code || '...'}) *</label>
<input type="number" min="0.0001" step="0.0001" className="input" placeholder="e.g., 0.018"
value={addForm.exchangeRate} onChange={(e) => setAddForm({ ...addForm, exchangeRate: e.target.value })} />
<label className="label">Rate (1 {addForm.fromCurrency} = ? {addForm.toCurrency}) *</label>
<input type="number" min="0.000001" step="0.000001" className="input" placeholder="e.g., 3.25"
value={addForm.rate} onChange={(e) => setAddForm({ ...addForm, rate: e.target.value })} />
</div>
<div className="flex gap-2 justify-end pt-2">
<ActionButton variant="secondary" onClick={() => { setShowAddModal(false); setError(null); }}>Cancel</ActionButton>
<ActionButton
loading={createMutation.isPending}
onClick={() => {
if (!addForm.code || !addForm.name || !addForm.symbol || !addForm.exchangeRate) {
setError('All fields are required'); return;
}
const rate = parseFloat(addForm.exchangeRate);
if (isNaN(rate) || rate <= 0) { setError('Exchange rate must be a positive number'); return; }
createMutation.mutate({ code: addForm.code, name: addForm.name, symbol: addForm.symbol, exchangeRate: rate });
}}
>
Add Currency
<ActionButton loading={createMutation.isPending} onClick={() => {
if (addForm.fromCurrency === addForm.toCurrency) { setError('From and To currencies must differ'); return; }
const rate = parseFloat(addForm.rate);
if (isNaN(rate) || rate <= 0) { setError('Rate must be a positive number'); return; }
createMutation.mutate({ fromCurrency: addForm.fromCurrency, toCurrency: addForm.toCurrency, rate });
}}>
Add Rate
</ActionButton>
</div>
</div>
</Modal>
{/* Edit Modal */}
<Modal isOpen={!!editingRate} onClose={() => { setEditingRate(null); setError(null); }} title={`Edit Rate — ${editingRate?.fromCurrency}${editingRate?.toCurrency}`} size="sm">
<div className="space-y-4">
{error && (
<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>
<label className="label">Rate (1 {editingRate?.fromCurrency} = ? {editingRate?.toCurrency})</label>
<input type="number" min="0.000001" step="0.000001" className="input w-full"
value={rateInput} onChange={(e) => setRateInput(e.target.value)} autoFocus />
{rateInput && parseFloat(rateInput) > 0 && (
<p className="text-xs text-muted-foreground mt-1">
1 {editingRate?.toCurrency} = {(1 / parseFloat(rateInput)).toFixed(6)} {editingRate?.fromCurrency}
</p>
)}
</div>
<div className="flex gap-2 justify-end pt-2">
<ActionButton variant="secondary" onClick={() => { setEditingRate(null); setError(null); }}>Cancel</ActionButton>
<ActionButton loading={updateMutation.isPending} onClick={() => {
const rate = parseFloat(rateInput);
if (isNaN(rate) || rate <= 0) { setError('Rate must be a positive number'); return; }
updateMutation.mutate({ id: editingRate!.id, rate });
}}>
Save
</ActionButton>
</div>
</div>
</Modal>
{/* Delete Confirm */}
<ConfirmDialog
isOpen={!!deleteConfirm}
onClose={() => setDeleteConfirm(null)}
onConfirm={() => deleteMutation.mutate(deleteConfirm!.id)}
title="Delete Currency"
message={`Delete ${deleteConfirm?.code} (${CURRENCY_META[deleteConfirm?.code ?? '']?.name ?? deleteConfirm?.code})? This will remove the exchange rate record.`}
title="Delete Exchange Rate"
message={`Delete rate ${deleteConfirm?.fromCurrency} ${deleteConfirm?.toCurrency} (${deleteConfirm?.rate})?`}
confirmText="Delete"
isDanger
isLoading={deleteMutation.isPending}
/>
<Modal
isOpen={!!editingRate}
onClose={() => { setEditingRate(null); setError(null); }}
title={`Update Rate — ${editingRate?.code}`}
size="sm"
>
<div className="space-y-4">
{error && (
<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="p-3 bg-muted/40 rounded-lg text-sm">
<span className="text-muted-foreground">Currency: </span>
<span className="font-semibold">{editingRate?.code} {CURRENCY_META[editingRate?.code ?? '']?.name}</span>
</div>
<div>
<label className="label">
1 {editingRate?.baseCurrencyCode} = ? {editingRate?.code}
</label>
<input
type="number"
min="0.0001"
step="0.0001"
value={rateInput}
onChange={(e) => setRateInput(e.target.value)}
className="input w-full"
placeholder="e.g., 3.25"
autoFocus
/>
{rateInput && parseFloat(rateInput) > 0 && (
<p className="text-xs text-muted-foreground mt-1">
1 {editingRate?.code} = {(1 / parseFloat(rateInput)).toFixed(6)} {editingRate?.baseCurrencyCode}
</p>
)}
</div>
<div className="flex gap-2 justify-end pt-2">
<ActionButton variant="secondary" onClick={() => { setEditingRate(null); setError(null); }}>
Cancel
</ActionButton>
<ActionButton onClick={handleSave} loading={updateMutation.isPending}>
Save Rate
</ActionButton>
</div>
</div>
</Modal>
</div>
);
}

View File

@@ -140,7 +140,7 @@ export default function PassengersPage() {
</div>
),
},
{ key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone || p.passenger?.user?.phone || '—' },
{ key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone || '—' },
{ key: 'nationality', label: 'Nationality', sortable: true, render: (p: any) => p.nationality || 'N/A' },
{ key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' },
{ key: 'dateOfBirth', label: 'Date of Birth', sortable: true, render: (p: any) => p.dateOfBirth ? formatDate(p.dateOfBirth) : 'N/A' },

View File

@@ -189,7 +189,7 @@ export default function TicketsPage() {
const coach = ticket.seat?.coach?.number || 'N/A';
const bookingRef = ticket.booking?.bookingRef || 'N/A';
const ticketNum = ticket.ticketNumber || 'N/A';
const passenger = ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'Guest';
const passenger = ticket.booking?.passenger?.fullName || ticket.booking?.seats?.[0]?.passengerName || ticket.passengerName || 'Guest';
w.document.write(
'<!DOCTYPE html><html><head><meta charset="utf-8"/><title>Boarding Pass</title><style>' +
'*{box-sizing:border-box;margin:0;padding:0}' +
@@ -284,7 +284,7 @@ export default function TicketsPage() {
switch (key) {
case 'ticketNumber': return ticket.ticketNumber || 'N/A';
case 'booking': return ticket.booking?.bookingRef || 'N/A';
case 'passenger': return ticket.passengerName || ticket.booking?.seats?.[0]?.passengerName || ticket.booking?.passenger?.fullName || ticket.booking?.contactEmail || 'Guest';
case 'passenger': return ticket.passengerName || ticket.booking?.seats?.[0]?.passengerName || ticket.booking?.passenger?.fullName || 'Guest';
case 'trip': return (ticket.schedule?.originStation?.name || 'N/A') + ' - ' + (ticket.schedule?.destinationStation?.name || 'N/A');
case 'coach': return ticket.seat?.coach?.number || 'N/A';
case 'seat': return ticket.seat?.seatNumber || 'N/A';
@@ -327,7 +327,6 @@ export default function TicketsPage() {
const passengerName = ticket.passengerName ||
ticket.booking?.seats?.[0]?.passengerName ||
ticket.booking?.passenger?.fullName ||
ticket.booking?.contactEmail ||
'Guest';
return (
<div>
@@ -743,7 +742,7 @@ export default function TicketsPage() {
const t = selectedTicket;
const b = t.booking;
const isRoundTrip = b?.bookingType === 'ROUND_TRIP' || b?.bookingType === 'ROUND_TRIP_TRANSIT';
const passengerName = b?.seats?.[0]?.passengerName || b?.passenger?.fullName || b?.contactEmail || 'Guest';
const passengerName = b?.seats?.[0]?.passengerName || b?.passenger?.fullName || 'Guest';
return (
<div>
{/* Gradient header */}

View File

@@ -127,7 +127,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
title: 'System',
items: [
// { name: 'Agents', href: '/agents', icon: Briefcase, permission: PERMS.agents.view },
{ name: 'Users', href: '/settings/users', icon: Users, permission: PERMS.admin },
// { name: 'Users', href: '/settings/users', icon: Users, permission: PERMS.admin },
{ name: 'Settings', href: '/settings', icon: Settings, permission: PERMS.admin },
{ name: 'Health', href: '/health', icon: Activity, permission: PERMS.admin },
{ name: 'App Releases', href: '/app-releases', icon: Smartphone, permission: PERMS.admin },

View File

@@ -399,6 +399,12 @@ export default function ReviewPage() {
passportNumber: !isEthiopian ? (p.passportNumber || '') : '',
passportCountry: !isEthiopian ? (p.passportCountry || '') : '',
nationality: p.nationality,
// Forward the contact details collected on the passengers page. The backend
// stores the first passenger's phone/email as the booking's contactPhone/
// contactEmail, which the notifications service uses to send the booking SMS
// and email. Omitting these leaves contactPhone null → "No SMS phone" warning.
...(p.phone ? { phone: p.phone } : {}),
...(p.email ? { email: p.email } : {}),
...(isRoundTrip
? { seatFareMinor: (p as any).outboundSeatFareMinor ?? undefined, returnSeatFareMinor: (p as any).inboundSeatFareMinor ?? undefined }
: { seatFareMinor: p.seatFareMinor ?? undefined }),

View File

@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react';
import { getTranslation, Language, useLanguage } from '@/lib/i18n';
import { Phone, Mail, MapPin, Send, Loader } from 'lucide-react';
import { Phone, Mail, MapPin } from 'lucide-react';
import { Footer } from '@/components/Footer';
const styles = `
@@ -110,145 +110,10 @@ const styles = `
.contact-card a:hover {
color: rgb(20, 113, 76);
}
.form-section {
padding: 60px 20px;
background-color: #f9fafb;
}
.dark .form-section {
background-color: #0f1117;
}
.form-container {
max-width: 42rem;
margin: 0 auto;
background: white;
border-radius: 18px;
padding: 32px;
border: 1px solid #e5e7eb;
}
.dark .form-container {
background: #1f2937;
border-color: #374151;
}
.form-container h2 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 24px;
color: #111827;
}
.dark .form-container h2 {
color: #f3f4f6;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
margin-bottom: 8px;
}
.dark .form-group label {
color: #d1d5db;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 12px 16px;
border: 2px solid #e5e7eb;
border-radius: 12px;
font-size: 1rem;
font-family: inherit;
transition: all 0.2s;
box-sizing: border-box;
background: white;
color: #111827;
}
.dark .form-group input,
.dark .form-group textarea {
background: #111827;
color: #f3f4f6;
border-color: #374151;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: rgb(20, 113, 76);
box-shadow: 0 0 0 3px rgba(20, 113, 76, 0.1);
}
.form-submit {
width: 100%;
padding: 14px 20px;
background-color: rgb(20, 113, 76);
color: white;
border: none;
border-radius: 12px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 8px;
}
.form-submit:hover {
background-color: rgb(16, 89, 60);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
}
.form-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.alert {
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 0.875rem;
}
.alert-success {
background-color: #dbeafe;
color: #1e40af;
}
.dark .alert-success {
background-color: rgba(20, 113, 76, 0.1);
color: #a7f3d0;
}
.alert-error {
background-color: #fee2e2;
color: #991b1b;
}
.dark .alert-error {
background-color: rgba(239, 68, 68, 0.1);
color: #fca5a5;
}
`;
export default function Contact() {
const [lang, setLang] = useState<Language>('en');
const [formData, setFormData] = useState({ name: '', email: '', subject: '', message: '' });
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const { getLang } = useLanguage();
const t = (key: string) => getTranslation(lang, key);
@@ -259,21 +124,6 @@ export default function Contact() {
return () => window.removeEventListener('languageChange', handleLanguageChange);
}, [getLang]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await new Promise(resolve => setTimeout(resolve, 1500));
setMessage({ type: 'success', text: t('contact.success') });
setFormData({ name: '', email: '', subject: '', message: '' });
} catch (error) {
setMessage({ type: 'error', text: t('contact.error') });
} finally {
setLoading(false);
}
};
const contactInfo = [
{ icon: Phone, title: t('contact.phone'), value: '9546', link: 'tel:9546' },
{ icon: Mail, title: t('contact.email'), value: 'edr_@edrsc.com', link: 'mailto:edr_@edrsc.com' },
@@ -303,74 +153,6 @@ export default function Contact() {
);
})}
</section>
<section className="form-section">
<div className="form-container">
<h2>{t('contact.form')}</h2>
{message && (
<div className={`alert alert-${message.type === 'success' ? 'success' : 'error'}`}>
{message.text}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>{t('contact.name')}</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="form-group">
<label>{t('contact.emailField')}</label>
<input
type="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
</div>
<div className="form-group">
<label>{t('contact.subject')}</label>
<input
type="text"
required
value={formData.subject}
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
/>
</div>
<div className="form-group">
<label>{t('contact.message')}</label>
<textarea
required
rows={5}
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
/>
</div>
<button type="submit" disabled={loading} className="form-submit">
{loading ? (
<>
<Loader size={18} />
{t('contact.sending')}
</>
) : (
<>
<Send size={18} />
{t('contact.send')}
</>
)}
</button>
</form>
</div>
</section>
</main>
<Footer />
</>

View File

@@ -48,13 +48,13 @@ export function Footer() {
<a href="https://web.facebook.com/ethiodjiboutirailwaysc" target="_blank" rel="noopener noreferrer" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="Facebook">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"/></svg>
</a>
<a href="https://twitter.com/edr" target="_blank" rel="noopener noreferrer" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="Twitter">
<a href="/" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="Twitter">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
</a>
<a href="https://instagram.com/edr" target="_blank" rel="noopener noreferrer" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="Instagram">
<a href="/" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="Instagram">
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="20" rx="5" ry="5"/><circle cx="12" cy="12" r="4"/><circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none"/></svg>
</a>
<a href="https://linkedin.com/company/edr" target="_blank" rel="noopener noreferrer" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="LinkedIn">
<a href="/" className="w-10 h-10 bg-white bg-opacity-20 rounded-lg flex items-center justify-center hover:bg-opacity-30 transition duration-200 transform hover:scale-110" aria-label="LinkedIn">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6zM2 9h4v12H2z"/><circle cx="4" cy="4" r="2"/></svg>
</a>
</div>