mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
Backoffice contact details, currency mgmt. updates
This commit is contained in:
@@ -425,14 +425,47 @@ export class BookingsService {
|
|||||||
? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>(
|
? 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)`,
|
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
|
||||||
[iamUserIds],
|
[iamUserIds],
|
||||||
)
|
).catch(() => [] as { id: string; email: string; name: any; phone_number: string | null }[])
|
||||||
: [];
|
: [];
|
||||||
const iamMap = new Map(iamRows.map(r => [r.id, r]));
|
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 mappedRegular = regularItems.map((booking: any) => {
|
||||||
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
|
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 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());
|
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 {
|
return {
|
||||||
id: booking.id,
|
id: booking.id,
|
||||||
bookingRef: booking.bookingRef,
|
bookingRef: booking.bookingRef,
|
||||||
@@ -441,8 +474,8 @@ export class BookingsService {
|
|||||||
currency: 'ETB',
|
currency: 'ETB',
|
||||||
displayCurrency: booking.displayCurrency,
|
displayCurrency: booking.displayCurrency,
|
||||||
displayTotalMinor: booking.displayTotalMinor,
|
displayTotalMinor: booking.displayTotalMinor,
|
||||||
contactEmail: booking.contactEmail,
|
contactEmail: resolvedEmail,
|
||||||
contactPhone: booking.contactPhone,
|
contactPhone: resolvedPhone,
|
||||||
bookingType: booking.bookingType,
|
bookingType: booking.bookingType,
|
||||||
packageId: booking.packageId ?? null,
|
packageId: booking.packageId ?? null,
|
||||||
priceTierId: (booking as any).priceTierId ?? null,
|
priceTierId: (booking as any).priceTierId ?? null,
|
||||||
@@ -530,6 +563,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) {
|
private async createOneWayBooking(dto: CreateBookingDto) {
|
||||||
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
|
const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } });
|
||||||
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
|
if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired');
|
||||||
@@ -547,7 +592,10 @@ export class BookingsService {
|
|||||||
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
||||||
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
|
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 { adultCount, childCount } = this.countPassengers(passengersData);
|
||||||
const fareCalculation = dto.packageId && dto.priceTierId
|
const fareCalculation = dto.packageId && dto.priceTierId
|
||||||
? await this.calculatePackageFare(dto.priceTierId, adultCount, childCount)
|
? await this.calculatePackageFare(dto.priceTierId, adultCount, childCount)
|
||||||
@@ -604,6 +652,8 @@ export class BookingsService {
|
|||||||
childCount,
|
childCount,
|
||||||
displayCurrency,
|
displayCurrency,
|
||||||
displayTotalMinor,
|
displayTotalMinor,
|
||||||
|
contactEmail: iamContact.contactEmail,
|
||||||
|
contactPhone: iamContact.contactPhone,
|
||||||
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||||
seats: {
|
seats: {
|
||||||
create: passengersWithFares.map(p => ({
|
create: passengersWithFares.map(p => ({
|
||||||
@@ -675,7 +725,10 @@ export class BookingsService {
|
|||||||
throw new NotFoundException('Origin or destination stops not found');
|
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);
|
const { adultCount, childCount } = this.countPassengers(passengersData);
|
||||||
|
|
||||||
// Package bookings use fixed tier price split equally across both legs
|
// Package bookings use fixed tier price split equally across both legs
|
||||||
@@ -782,6 +835,8 @@ export class BookingsService {
|
|||||||
returnHoldId: dto.returnHoldId,
|
returnHoldId: dto.returnHoldId,
|
||||||
returnSeatClassId: dto.returnSeatClassId,
|
returnSeatClassId: dto.returnSeatClassId,
|
||||||
returnLegStatus: 'NEITHER_USED',
|
returnLegStatus: 'NEITHER_USED',
|
||||||
|
contactEmail: iamContact.contactEmail,
|
||||||
|
contactPhone: iamContact.contactPhone,
|
||||||
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
...(dto.packageId ? { packageId: dto.packageId, priceTierId: dto.priceTierId } : {}),
|
||||||
seats: {
|
seats: {
|
||||||
create: [
|
create: [
|
||||||
@@ -891,7 +946,10 @@ export class BookingsService {
|
|||||||
if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule');
|
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');
|
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 { adultCount, childCount } = this.countPassengers(passengersData);
|
||||||
|
|
||||||
const leg2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId;
|
const leg2SeatClassId = dto.leg2SeatClassId ?? dto.seatClassId;
|
||||||
@@ -966,6 +1024,8 @@ export class BookingsService {
|
|||||||
leg2OriginStationId: dto.transitStationId,
|
leg2OriginStationId: dto.transitStationId,
|
||||||
leg2DestinationStationId: dto.leg2DestinationStationId,
|
leg2DestinationStationId: dto.leg2DestinationStationId,
|
||||||
leg2SeatClassId,
|
leg2SeatClassId,
|
||||||
|
contactEmail: iamContact.contactEmail,
|
||||||
|
contactPhone: iamContact.contactPhone,
|
||||||
seats: {
|
seats: {
|
||||||
create: [
|
create: [
|
||||||
...passengersWithFares.map(p => ({
|
...passengersWithFares.map(p => ({
|
||||||
@@ -1080,7 +1140,10 @@ export class BookingsService {
|
|||||||
if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit station not found');
|
if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit station not found');
|
||||||
if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination not found');
|
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 { adultCount, childCount } = this.countPassengers(passengersData);
|
||||||
const nat = passengersData[0]?.nationality;
|
const nat = passengersData[0]?.nationality;
|
||||||
|
|
||||||
@@ -1178,6 +1241,8 @@ export class BookingsService {
|
|||||||
returnLeg2DestStationId: dto.returnLeg2DestinationStationId,
|
returnLeg2DestStationId: dto.returnLeg2DestinationStationId,
|
||||||
returnLeg2SeatClassId: retL2SeatClassId,
|
returnLeg2SeatClassId: retL2SeatClassId,
|
||||||
returnLegStatus: 'NEITHER_USED',
|
returnLegStatus: 'NEITHER_USED',
|
||||||
|
contactEmail: iamContact.contactEmail,
|
||||||
|
contactPhone: iamContact.contactPhone,
|
||||||
seats: {
|
seats: {
|
||||||
create: [
|
create: [
|
||||||
// Outbound leg-1 (sequence 1)
|
// Outbound leg-1 (sequence 1)
|
||||||
|
|||||||
@@ -53,6 +53,23 @@ export class GuestBookingService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createGuestBooking(dto: CreateGuestBookingDto, req?: any) {
|
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 === 'ROUND_TRIP') return this.createGuestRoundTripBooking(dto, req);
|
||||||
if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto, req);
|
if (dto.bookingType === 'TRANSIT') return this.createGuestTransitBooking(dto, req);
|
||||||
if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createGuestRoundTripTransitBooking(dto, req);
|
if (dto.bookingType === 'ROUND_TRIP_TRANSIT') return this.createGuestRoundTripTransitBooking(dto, req);
|
||||||
|
|||||||
@@ -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) {
|
async deleteCurrency(id: string) {
|
||||||
const existing = await this.prisma.currencyExchangeRate.findUnique({
|
const existing = await this.prisma.currencyExchangeRate.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -122,15 +129,6 @@ export class CurrenciesService {
|
|||||||
return { message: 'Currency deleted successfully' };
|
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 {
|
private getCurrencyName(code: string): string {
|
||||||
const names: Record<string, string> = {
|
const names: Record<string, string> = {
|
||||||
ETB: 'Ethiopian Birr',
|
ETB: 'Ethiopian Birr',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { HttpModule } from '@nestjs/axios';
|
|
||||||
import { CurrencyService } from './currency.service';
|
import { CurrencyService } from './currency.service';
|
||||||
|
import { CurrencyController } from './currency.controller';
|
||||||
import { PrismaModule } from '../../common/prisma.module';
|
import { PrismaModule } from '../../common/prisma.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, HttpModule],
|
imports: [PrismaModule],
|
||||||
|
controllers: [CurrencyController],
|
||||||
providers: [CurrencyService],
|
providers: [CurrencyService],
|
||||||
exports: [CurrencyService],
|
exports: [CurrencyService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
import {
|
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
|
||||||
BadRequestException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { HttpService } from '@nestjs/axios';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { firstValueFrom } from 'rxjs';
|
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
@@ -24,11 +16,7 @@ const CHARGE_CURRENCY_DECIMALS: Record<string, number> = {
|
|||||||
export class CurrencyService {
|
export class CurrencyService {
|
||||||
private readonly logger = new Logger(CurrencyService.name);
|
private readonly logger = new Logger(CurrencyService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly httpService: HttpService,
|
|
||||||
private readonly configService: ConfigService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a stored display-currency minor amount to the charge major amount
|
* Converts a stored display-currency minor amount to the charge major amount
|
||||||
@@ -148,53 +136,6 @@ export class CurrencyService {
|
|||||||
return Number(exchangeRate.rate);
|
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() {
|
async listRates() {
|
||||||
return this.prisma.currencyExchangeRate.findMany({
|
return this.prisma.currencyExchangeRate.findMany({
|
||||||
orderBy: [{ fromCurrency: 'asc' }, { toCurrency: 'asc' }, { effectiveDate: 'desc' }],
|
orderBy: [{ fromCurrency: 'asc' }, { toCurrency: 'asc' }, { effectiveDate: 'desc' }],
|
||||||
|
|||||||
@@ -49,9 +49,4 @@ export class CurrencyController {
|
|||||||
return this.currency.deleteRate(id);
|
return this.currency.deleteRate(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('sync')
|
|
||||||
@ApiOperation({ summary: 'Trigger exchange rate sync from external provider' })
|
|
||||||
sync() {
|
|
||||||
return this.currency.syncExchangeRates();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,10 +39,18 @@ export class PassengersService {
|
|||||||
const where: any = {};
|
const where: any = {};
|
||||||
|
|
||||||
if (search) {
|
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 = [
|
where.OR = [
|
||||||
{ fullName: { contains: search, mode: 'insensitive' } },
|
{ fullName: { contains: search, mode: 'insensitive' } },
|
||||||
{ passenger: { user: { email: { contains: search, mode: 'insensitive' } } } },
|
...(matchedPassengers.length > 0 ? [{ passengerId: { in: matchedPassengers.map(p => p.id) } }] : []),
|
||||||
{ passenger: { user: { phone: { contains: search, mode: 'insensitive' } } } },
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +74,6 @@ export class PassengersService {
|
|||||||
include: {
|
include: {
|
||||||
passenger: {
|
passenger: {
|
||||||
include: {
|
include: {
|
||||||
user: true,
|
|
||||||
loyalty: true,
|
loyalty: true,
|
||||||
wallet: true,
|
wallet: true,
|
||||||
_count: { select: { bookings: true } },
|
_count: { select: { bookings: true } },
|
||||||
@@ -106,17 +113,13 @@ export class PassengersService {
|
|||||||
return {
|
return {
|
||||||
items: items.map(profile => {
|
items: items.map(profile => {
|
||||||
const passenger = profile.passenger;
|
const passenger = profile.passenger;
|
||||||
const localUser = (passenger as any)?.user ?? null;
|
|
||||||
const iam = passenger?.iamUserId ? iamMap.get(passenger.iamUserId) : undefined;
|
const iam = passenger?.iamUserId ? iamMap.get(passenger.iamUserId) : undefined;
|
||||||
const faydaVerified = localUser?.faydaVerified === true
|
const faydaVerified = iam?.metadata?.faydaVerified === true
|
||||||
|| iam?.metadata?.faydaVerified === true
|
|
||||||
|| 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 guestBooking = (passenger as any)?.bookings?.[0] ?? null;
|
||||||
const guestSeat = guestBooking?.seats?.[0] ?? null;
|
const guestSeat = guestBooking?.seats?.[0] ?? null;
|
||||||
|
|
||||||
// Parse notes JSON to extract phone and other data
|
|
||||||
let notesData: any = null;
|
let notesData: any = null;
|
||||||
if (profile.notes) {
|
if (profile.notes) {
|
||||||
try {
|
try {
|
||||||
@@ -129,25 +132,23 @@ export class PassengersService {
|
|||||||
return {
|
return {
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
fullName: profile.fullName,
|
fullName: profile.fullName,
|
||||||
email: localUser?.email ?? iam?.email ?? notesData?.email ?? guestBooking?.contactEmail ?? null,
|
email: iam?.email ?? notesData?.email ?? guestBooking?.contactEmail ?? null,
|
||||||
phone: localUser?.phone ?? iam?.phone_number ?? notesData?.phone ?? guestBooking?.contactPhone ?? null,
|
phone: iam?.phone_number ?? notesData?.phone ?? guestBooking?.contactPhone ?? null,
|
||||||
gender: profile.gender ?? localUser?.gender ?? iam?.metadata?.gender ?? null,
|
gender: profile.gender ?? iam?.metadata?.gender ?? null,
|
||||||
dateOfBirth: profile.dateOfBirth
|
dateOfBirth: profile.dateOfBirth
|
||||||
? new Date(profile.dateOfBirth).toISOString().split('T')[0]
|
? new Date(profile.dateOfBirth).toISOString().split('T')[0]
|
||||||
: (localUser?.dateOfBirth
|
: (iam?.metadata?.dateOfBirth ?? null),
|
||||||
? (localUser.dateOfBirth instanceof Date ? localUser.dateOfBirth.toISOString().split('T')[0] : localUser.dateOfBirth)
|
nationality: iam?.metadata?.nationality ?? notesData?.nationality ?? (guestSeat?.passportCountry ? (guestSeat.passportCountry === 'Ethiopia' ? 'Ethiopian' : guestSeat.passportCountry) : null),
|
||||||
: iam?.metadata?.dateOfBirth ?? null),
|
nationalityCode: iam?.metadata?.nationalityCode ?? 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,
|
|
||||||
faydaVerified,
|
faydaVerified,
|
||||||
faydaVerifiedAt: localUser?.faydaVerifiedAt ?? iam?.metadata?.faydaVerifiedAt ?? null,
|
faydaVerifiedAt: iam?.metadata?.faydaVerifiedAt ?? null,
|
||||||
passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? notesData?.passportNumber ?? guestSeat?.passportNumber ?? null,
|
passportNumber: iam?.metadata?.passportNumber ?? notesData?.passportNumber ?? guestSeat?.passportNumber ?? null,
|
||||||
passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? notesData?.passportCountry ?? guestSeat?.passportCountry ?? null,
|
passportCountry: iam?.metadata?.passportCountry ?? notesData?.passportCountry ?? guestSeat?.passportCountry ?? null,
|
||||||
passportExpiryDate: localUser?.passportExpiryDate ?? iam?.metadata?.passportExpiryDate ?? null,
|
passportExpiryDate: iam?.metadata?.passportExpiryDate ?? null,
|
||||||
idDocumentType: profile.nationalId ? 'NATIONAL_ID' : (notesData?.idDocumentType ?? null),
|
idDocumentType: profile.nationalId ? 'NATIONAL_ID' : (notesData?.idDocumentType ?? null),
|
||||||
verified: faydaVerified,
|
verified: faydaVerified,
|
||||||
lastLoginAt: localUser?.lastLoginAt ?? null,
|
lastLoginAt: null,
|
||||||
role: localUser?.role ?? null,
|
role: null,
|
||||||
loyalty: passenger?.loyalty
|
loyalty: passenger?.loyalty
|
||||||
? { tier: passenger.loyalty.tier, pointsBalance: passenger.loyalty.pointsBalance, lifetimePoints: (passenger.loyalty as any).lifetimePoints ?? 0 }
|
? { tier: passenger.loyalty.tier, pointsBalance: passenger.loyalty.pointsBalance, lifetimePoints: (passenger.loyalty as any).lifetimePoints ?? 0 }
|
||||||
: null,
|
: null,
|
||||||
@@ -279,6 +280,8 @@ export class PassengersService {
|
|||||||
passengerName: p.passengerName,
|
passengerName: p.passengerName,
|
||||||
dateOfBirth: p.dateOfBirth,
|
dateOfBirth: p.dateOfBirth,
|
||||||
nationality: p.nationality,
|
nationality: p.nationality,
|
||||||
|
phone: p.phone ?? null,
|
||||||
|
email: p.email ?? null,
|
||||||
})),
|
})),
|
||||||
message: 'Passenger details saved successfully',
|
message: 'Passenger details saved successfully',
|
||||||
};
|
};
|
||||||
@@ -434,18 +437,12 @@ export class PassengersService {
|
|||||||
|
|
||||||
async deletePassenger(id: string, cascade = false) {
|
async deletePassenger(id: string, cascade = false) {
|
||||||
// id may be a TravelerProfile.id (from the list endpoint) or a Passenger.id
|
// id may be a TravelerProfile.id (from the list endpoint) or a Passenger.id
|
||||||
let passenger = await this.prisma.passenger.findUnique({
|
let passenger = await this.prisma.passenger.findUnique({ where: { id } });
|
||||||
where: { id },
|
|
||||||
include: { user: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!passenger) {
|
if (!passenger) {
|
||||||
const profile = await this.prisma.travelerProfile.findUnique({ where: { id } });
|
const profile = await this.prisma.travelerProfile.findUnique({ where: { id } });
|
||||||
if (!profile?.passengerId) throw new NotFoundException('Passenger not found');
|
if (!profile?.passengerId) throw new NotFoundException('Passenger not found');
|
||||||
passenger = await this.prisma.passenger.findUnique({
|
passenger = await this.prisma.passenger.findUnique({ where: { id: profile.passengerId } });
|
||||||
where: { id: profile.passengerId },
|
|
||||||
include: { user: true },
|
|
||||||
});
|
|
||||||
if (!passenger) throw new NotFoundException('Passenger not found');
|
if (!passenger) throw new NotFoundException('Passenger not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,7 +451,7 @@ export class PassengersService {
|
|||||||
if (!cascade) {
|
if (!cascade) {
|
||||||
const usage = await this.checkPassengerUsage(passengerId);
|
const usage = await this.checkPassengerUsage(passengerId);
|
||||||
if (usage.isInUse && usage.constraints) {
|
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);
|
throw new DeleteOperationException('Passenger', passengerName, usage.constraints);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// Daily at 02:00 EAT: purge expired/stale records to enforce data retention.
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -255,8 +255,8 @@ function BookingsPageContent() {
|
|||||||
{
|
{
|
||||||
key: 'contact', label: 'Primary contact',
|
key: 'contact', label: 'Primary contact',
|
||||||
render: (booking: any) => {
|
render: (booking: any) => {
|
||||||
const phone = booking.contactPhone || booking.passenger?.phone || '—';
|
const phone = booking.contactPhone || booking.passenger?.phone || booking.seats?.[0]?.phone || '—';
|
||||||
const email = booking.contactEmail || booking.passenger?.email || '—';
|
const email = booking.contactEmail || booking.passenger?.email || booking.seats?.[0]?.email || '—';
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{phone}</div>
|
<div className="font-medium">{phone}</div>
|
||||||
@@ -403,9 +403,9 @@ function BookingsPageContent() {
|
|||||||
<section>
|
<section>
|
||||||
<SectionHeader title="Passenger" />
|
<SectionHeader title="Passenger" />
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
<Field label="Full Name" value={b.passenger?.fullName || b.contactEmail} />
|
<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="Email" value={b.contactEmail || b.passenger?.email || '—'} />
|
||||||
<Field label="Phone" value={b.contactPhone || b.passenger?.phone} />
|
<Field label="Phone" value={b.contactPhone || b.passenger?.phone || '—'} />
|
||||||
<Field label="Passenger ID" value={b.passengerId} mono truncate />
|
<Field label="Passenger ID" value={b.passengerId} mono truncate />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,55 +2,43 @@
|
|||||||
|
|
||||||
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 { Edit, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
import { Edit, Loader2, Plus, Trash2 } 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 ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
|
||||||
interface CurrencyRate {
|
interface ExchangeRate {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
fromCurrency: string;
|
||||||
name: string;
|
toCurrency: string;
|
||||||
symbol: string;
|
rate: number;
|
||||||
baseCurrencyCode: string;
|
source: string;
|
||||||
exchangeRate: number;
|
effectiveDate: string;
|
||||||
isActive: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CURRENCY_META: Record<string, { name: string; symbol: string }> = {
|
const CURRENCY_META: Record<string, { name: string }> = {
|
||||||
ETB: { name: 'Ethiopian Birr', symbol: 'Br' },
|
ETB: { name: 'Ethiopian Birr' },
|
||||||
DJF: { name: 'Djiboutian Franc', symbol: 'Fdj' },
|
DJF: { name: 'Djiboutian Franc' },
|
||||||
USD: { name: 'US Dollar', symbol: '$' },
|
USD: { name: 'US Dollar' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CURRENCY_OPTIONS = ['ETB', 'DJF', 'USD'];
|
||||||
|
|
||||||
export default function CurrenciesPage() {
|
export default function CurrenciesPage() {
|
||||||
const [editingRate, setEditingRate] = useState<CurrencyRate | null>(null);
|
const [editingRate, setEditingRate] = useState<ExchangeRate | null>(null);
|
||||||
const [rateInput, setRateInput] = useState('');
|
const [rateInput, setRateInput] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showAddModal, setShowAddModal] = useState(false);
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
const [addForm, setAddForm] = useState({ code: '', name: '', symbol: '', exchangeRate: '' });
|
const [addForm, setAddForm] = useState({ fromCurrency: 'ETB', toCurrency: 'DJF', rate: '' });
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<CurrencyRate | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<ExchangeRate | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: currencies = [], isLoading } = useQuery<CurrencyRate[]>({
|
const { data: rates = [], isLoading } = useQuery<ExchangeRate[]>({
|
||||||
queryKey: ['currencies'],
|
queryKey: ['currencies'],
|
||||||
queryFn: () => apiClient.get('/currencies'),
|
queryFn: () => apiClient.get('/currencies'),
|
||||||
});
|
select: (d: any) => (Array.isArray(d) ? d : d?.data ?? d?.items ?? []),
|
||||||
|
|
||||||
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');
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
@@ -58,10 +46,21 @@ export default function CurrenciesPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['currencies'] });
|
queryClient.invalidateQueries({ queryKey: ['currencies'] });
|
||||||
setShowAddModal(false);
|
setShowAddModal(false);
|
||||||
setAddForm({ code: '', name: '', symbol: '', exchangeRate: '' });
|
setAddForm({ fromCurrency: 'ETB', toCurrency: 'DJF', rate: '' });
|
||||||
setError(null);
|
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({
|
const deleteMutation = useMutation({
|
||||||
@@ -70,88 +69,70 @@ export default function CurrenciesPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['currencies'] });
|
queryClient.invalidateQueries({ queryKey: ['currencies'] });
|
||||||
setDeleteConfirm(null);
|
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 = [
|
const columns = [
|
||||||
{
|
{
|
||||||
key: 'code',
|
key: 'pair',
|
||||||
label: 'Currency',
|
label: 'Pair',
|
||||||
render: (c: CurrencyRate) => (
|
render: (r: ExchangeRate) => (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-2xl font-bold text-muted-foreground w-10 text-center">
|
<span className="font-mono font-semibold">{r.fromCurrency}</span>
|
||||||
{CURRENCY_META[c.code]?.symbol ?? c.symbol}
|
<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>
|
</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>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'baseCurrencyCode',
|
key: 'rate',
|
||||||
label: 'Base',
|
label: 'Rate',
|
||||||
render: (c: CurrencyRate) => (
|
render: (r: ExchangeRate) => (
|
||||||
<span className="font-mono text-sm text-muted-foreground">{c.baseCurrencyCode}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'exchangeRate',
|
|
||||||
label: 'Exchange Rate',
|
|
||||||
render: (c: CurrencyRate) => (
|
|
||||||
<div>
|
<div>
|
||||||
<div className="font-mono font-semibold">
|
<div className="font-mono font-semibold">
|
||||||
1 {c.baseCurrencyCode} = {c.exchangeRate} {c.code}
|
1 {r.fromCurrency} = {r.rate} {r.toCurrency}
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
1 {c.code} = {(1 / c.exchangeRate).toFixed(6)} {c.baseCurrencyCode}
|
|
||||||
</div>
|
</div>
|
||||||
|
{r.rate > 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
1 {r.toCurrency} = {(1 / r.rate).toFixed(6)} {r.fromCurrency}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'createdAt',
|
key: 'source',
|
||||||
label: 'Last Updated',
|
label: 'Source',
|
||||||
render: (c: CurrencyRate) => (
|
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">
|
<span className="text-sm text-muted-foreground">
|
||||||
{new Date(c.createdAt).toLocaleDateString()}
|
{r.effectiveDate ? new Date(r.effectiveDate).toLocaleDateString() : '—'}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const actions = [
|
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',
|
label: 'Delete',
|
||||||
onClick: (c: CurrencyRate) => setDeleteConfirm(c),
|
onClick: (r: ExchangeRate) => setDeleteConfirm(r),
|
||||||
variant: 'danger' as const,
|
variant: 'danger' as const,
|
||||||
icon: Trash2,
|
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 className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">Exchange Rates</h1>
|
<h1 className="text-3xl font-bold text-foreground">Exchange Rates</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">Manage currency exchange rates</p>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ActionButton icon={Plus} onClick={() => { setError(null); setShowAddModal(true); }}>
|
||||||
|
Add Rate
|
||||||
|
</ActionButton>
|
||||||
</div>
|
</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">
|
<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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card">
|
<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 ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DataTable
|
<DataTable
|
||||||
data={currenciesArray}
|
data={rates}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
loading={false}
|
loading={false}
|
||||||
@@ -226,117 +170,85 @@ export default function CurrenciesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal
|
{/* Add Modal */}
|
||||||
isOpen={showAddModal}
|
<Modal isOpen={showAddModal} onClose={() => { setShowAddModal(false); setError(null); }} title="Add Exchange Rate" size="sm">
|
||||||
onClose={() => { setShowAddModal(false); setError(null); }}
|
|
||||||
title="Add Currency"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{error && (
|
{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="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 className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Code *</label>
|
<label className="label">From *</label>
|
||||||
<input className="input uppercase" placeholder="e.g., EUR" maxLength={5}
|
<select className="input" value={addForm.fromCurrency} onChange={(e) => setAddForm({ ...addForm, fromCurrency: e.target.value })}>
|
||||||
value={addForm.code} onChange={(e) => setAddForm({ ...addForm, code: e.target.value.toUpperCase() })} />
|
{CURRENCY_OPTIONS.map(c => <option key={c} value={c}>{c} — {CURRENCY_META[c]?.name ?? c}</option>)}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Symbol *</label>
|
<label className="label">To *</label>
|
||||||
<input className="input" placeholder="e.g., €"
|
<select className="input" value={addForm.toCurrency} onChange={(e) => setAddForm({ ...addForm, toCurrency: e.target.value })}>
|
||||||
value={addForm.symbol} onChange={(e) => setAddForm({ ...addForm, symbol: e.target.value })} />
|
{CURRENCY_OPTIONS.map(c => <option key={c} value={c}>{c} — {CURRENCY_META[c]?.name ?? c}</option>)}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Name *</label>
|
<label className="label">Rate (1 {addForm.fromCurrency} = ? {addForm.toCurrency}) *</label>
|
||||||
<input className="input" placeholder="e.g., Euro"
|
<input type="number" min="0.000001" step="0.000001" className="input" placeholder="e.g., 3.25"
|
||||||
value={addForm.name} onChange={(e) => setAddForm({ ...addForm, name: e.target.value })} />
|
value={addForm.rate} onChange={(e) => setAddForm({ ...addForm, rate: 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 })} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 justify-end pt-2">
|
<div className="flex gap-2 justify-end pt-2">
|
||||||
<ActionButton variant="secondary" onClick={() => { setShowAddModal(false); setError(null); }}>Cancel</ActionButton>
|
<ActionButton variant="secondary" onClick={() => { setShowAddModal(false); setError(null); }}>Cancel</ActionButton>
|
||||||
<ActionButton
|
<ActionButton loading={createMutation.isPending} onClick={() => {
|
||||||
loading={createMutation.isPending}
|
if (addForm.fromCurrency === addForm.toCurrency) { setError('From and To currencies must differ'); return; }
|
||||||
onClick={() => {
|
const rate = parseFloat(addForm.rate);
|
||||||
if (!addForm.code || !addForm.name || !addForm.symbol || !addForm.exchangeRate) {
|
if (isNaN(rate) || rate <= 0) { setError('Rate must be a positive number'); return; }
|
||||||
setError('All fields are required'); return;
|
createMutation.mutate({ fromCurrency: addForm.fromCurrency, toCurrency: addForm.toCurrency, rate });
|
||||||
}
|
}}>
|
||||||
const rate = parseFloat(addForm.exchangeRate);
|
Add Rate
|
||||||
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>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</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
|
<ConfirmDialog
|
||||||
isOpen={!!deleteConfirm}
|
isOpen={!!deleteConfirm}
|
||||||
onClose={() => setDeleteConfirm(null)}
|
onClose={() => setDeleteConfirm(null)}
|
||||||
onConfirm={() => deleteMutation.mutate(deleteConfirm!.id)}
|
onConfirm={() => deleteMutation.mutate(deleteConfirm!.id)}
|
||||||
title="Delete Currency"
|
title="Delete Exchange Rate"
|
||||||
message={`Delete ${deleteConfirm?.code} (${CURRENCY_META[deleteConfirm?.code ?? '']?.name ?? deleteConfirm?.code})? This will remove the exchange rate record.`}
|
message={`Delete rate ${deleteConfirm?.fromCurrency} → ${deleteConfirm?.toCurrency} (${deleteConfirm?.rate})?`}
|
||||||
confirmText="Delete"
|
confirmText="Delete"
|
||||||
isDanger
|
isDanger
|
||||||
isLoading={deleteMutation.isPending}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export default function PassengersPage() {
|
|||||||
</div>
|
</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: '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: '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' },
|
{ key: 'dateOfBirth', label: 'Date of Birth', sortable: true, render: (p: any) => p.dateOfBirth ? formatDate(p.dateOfBirth) : 'N/A' },
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export default function TicketsPage() {
|
|||||||
const coach = ticket.seat?.coach?.number || 'N/A';
|
const coach = ticket.seat?.coach?.number || 'N/A';
|
||||||
const bookingRef = ticket.booking?.bookingRef || 'N/A';
|
const bookingRef = ticket.booking?.bookingRef || 'N/A';
|
||||||
const ticketNum = ticket.ticketNumber || '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(
|
w.document.write(
|
||||||
'<!DOCTYPE html><html><head><meta charset="utf-8"/><title>Boarding Pass</title><style>' +
|
'<!DOCTYPE html><html><head><meta charset="utf-8"/><title>Boarding Pass</title><style>' +
|
||||||
'*{box-sizing:border-box;margin:0;padding:0}' +
|
'*{box-sizing:border-box;margin:0;padding:0}' +
|
||||||
@@ -284,7 +284,7 @@ export default function TicketsPage() {
|
|||||||
switch (key) {
|
switch (key) {
|
||||||
case 'ticketNumber': return ticket.ticketNumber || 'N/A';
|
case 'ticketNumber': return ticket.ticketNumber || 'N/A';
|
||||||
case 'booking': return ticket.booking?.bookingRef || '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 'trip': return (ticket.schedule?.originStation?.name || 'N/A') + ' - ' + (ticket.schedule?.destinationStation?.name || 'N/A');
|
||||||
case 'coach': return ticket.seat?.coach?.number || 'N/A';
|
case 'coach': return ticket.seat?.coach?.number || 'N/A';
|
||||||
case 'seat': return ticket.seat?.seatNumber || 'N/A';
|
case 'seat': return ticket.seat?.seatNumber || 'N/A';
|
||||||
@@ -327,7 +327,6 @@ export default function TicketsPage() {
|
|||||||
const passengerName = ticket.passengerName ||
|
const passengerName = ticket.passengerName ||
|
||||||
ticket.booking?.seats?.[0]?.passengerName ||
|
ticket.booking?.seats?.[0]?.passengerName ||
|
||||||
ticket.booking?.passenger?.fullName ||
|
ticket.booking?.passenger?.fullName ||
|
||||||
ticket.booking?.contactEmail ||
|
|
||||||
'Guest';
|
'Guest';
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -743,7 +742,7 @@ export default function TicketsPage() {
|
|||||||
const t = selectedTicket;
|
const t = selectedTicket;
|
||||||
const b = t.booking;
|
const b = t.booking;
|
||||||
const isRoundTrip = b?.bookingType === 'ROUND_TRIP' || b?.bookingType === 'ROUND_TRIP_TRANSIT';
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Gradient header */}
|
{/* Gradient header */}
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
|||||||
title: 'System',
|
title: 'System',
|
||||||
items: [
|
items: [
|
||||||
// { name: 'Agents', href: '/agents', icon: Briefcase, permission: PERMS.agents.view },
|
// { 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: 'Settings', href: '/settings', icon: Settings, permission: PERMS.admin },
|
||||||
{ name: 'Health', href: '/health', icon: Activity, permission: PERMS.admin },
|
{ name: 'Health', href: '/health', icon: Activity, permission: PERMS.admin },
|
||||||
{ name: 'App Releases', href: '/app-releases', icon: Smartphone, permission: PERMS.admin },
|
{ name: 'App Releases', href: '/app-releases', icon: Smartphone, permission: PERMS.admin },
|
||||||
|
|||||||
Reference in New Issue
Block a user