mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Boarding, ticketing, class fare edit updates
This commit is contained in:
@@ -68,16 +68,44 @@ export class FareEngineService {
|
|||||||
let ratePerKmMinor: number;
|
let ratePerKmMinor: number;
|
||||||
let fareSource: string;
|
let fareSource: string;
|
||||||
|
|
||||||
if (fareRule) {
|
// 1. Segment override: exact origin→destination stop pair on this route
|
||||||
// Flat fare from FareRule — distance is informational only
|
const segmentOverride = await this.prisma.segmentFareRule.findFirst({
|
||||||
|
where: {
|
||||||
|
routeId: route.id,
|
||||||
|
seatClassId: dto.seatClassId,
|
||||||
|
originStopSequence: originStop.sequence,
|
||||||
|
destinationStopSequence: destStop.sequence,
|
||||||
|
validFrom: { lte: now },
|
||||||
|
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||||
|
nationality: dto.nationality ?? null,
|
||||||
|
},
|
||||||
|
}) ?? await this.prisma.segmentFareRule.findFirst({
|
||||||
|
where: {
|
||||||
|
routeId: route.id,
|
||||||
|
seatClassId: dto.seatClassId,
|
||||||
|
originStopSequence: originStop.sequence,
|
||||||
|
destinationStopSequence: destStop.sequence,
|
||||||
|
validFrom: { lte: now },
|
||||||
|
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||||
|
nationality: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (segmentOverride) {
|
||||||
|
// Flat override for this exact segment — baseFareMinor is the total base, not a per-km rate
|
||||||
|
baseFarePerPassengerMinor = segmentOverride.baseFareMinor;
|
||||||
|
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
|
||||||
|
fareSource = 'SEGMENT_FARE_RULE';
|
||||||
|
} else if (fareRule?.tripId) {
|
||||||
|
// Schedule-scoped flat override
|
||||||
baseFarePerPassengerMinor = fareRule.baseFareMinor;
|
baseFarePerPassengerMinor = fareRule.baseFareMinor;
|
||||||
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
|
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
|
||||||
fareSource = fareRule.tripId ? 'SCHEDULE_FARE_RULE' : 'ROUTE_FARE_RULE';
|
fareSource = 'SCHEDULE_FARE_RULE';
|
||||||
} else {
|
} else {
|
||||||
// Distance × rate fallback
|
// Default: distance-based using live SeatClass rate
|
||||||
ratePerKmMinor = seatClass.baseFareMinor;
|
ratePerKmMinor = seatClass.baseFareMinor;
|
||||||
baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
|
baseFarePerPassengerMinor = Math.round(ratePerKmMinor * totalDistanceKm);
|
||||||
fareSource = 'DISTANCE_RATE';
|
fareSource = 'SEAT_CLASS_BASE_FARE';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Premium and insurance fees applied per passenger
|
// Premium and insurance fees applied per passenger
|
||||||
@@ -146,6 +174,7 @@ export class FareEngineService {
|
|||||||
routeCode: route.code,
|
routeCode: route.code,
|
||||||
originName: originStation?.name ?? dto.originStationId,
|
originName: originStation?.name ?? dto.originStationId,
|
||||||
destinationName: destStation?.name ?? dto.destinationStationId,
|
destinationName: destStation?.name ?? dto.destinationStationId,
|
||||||
|
seatClassId: seatClass.id,
|
||||||
seatClassName: seatClass.name,
|
seatClassName: seatClass.name,
|
||||||
totalDistanceKm,
|
totalDistanceKm,
|
||||||
ratePerKmMinor,
|
ratePerKmMinor,
|
||||||
|
|||||||
@@ -79,11 +79,10 @@ export class UpdateClassDto {
|
|||||||
@ApiPropertyOptional({ example: 'coach-type-uuid' }) @IsOptional() @IsString() coachTypeId?: string;
|
@ApiPropertyOptional({ example: 'coach-type-uuid' }) @IsOptional() @IsString() coachTypeId?: string;
|
||||||
@ApiPropertyOptional({ example: 'Economy' }) @IsOptional() @IsString() name?: string;
|
@ApiPropertyOptional({ example: 'Economy' }) @IsOptional() @IsString() name?: string;
|
||||||
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
|
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
|
||||||
@ApiPropertyOptional({ example: 500 }) @IsOptional() @IsInt() baseFareMinor?: number;
|
@ApiPropertyOptional({ example: 50 }) @IsOptional() @IsInt() baseFareMinor?: number;
|
||||||
@ApiPropertyOptional({ example: true })
|
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() premiumMinor?: number;
|
||||||
@IsOptional()
|
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() insuranceFeeMinor?: number;
|
||||||
@IsBoolean()
|
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() isActive?: boolean;
|
||||||
isActive?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GenerateSeatMapDto {
|
export class GenerateSeatMapDto {
|
||||||
|
|||||||
@@ -258,6 +258,8 @@ export class FleetService {
|
|||||||
name: dto.name,
|
name: dto.name,
|
||||||
description: dto.description,
|
description: dto.description,
|
||||||
baseFareMinor: dto.baseFareMinor,
|
baseFareMinor: dto.baseFareMinor,
|
||||||
|
premiumMinor: dto.premiumMinor,
|
||||||
|
insuranceFeeMinor: dto.insuranceFeeMinor,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (dto.isActive !== undefined) {
|
if (dto.isActive !== undefined) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||||
import { SchedulesService } from './schedules.service';
|
import { SchedulesService } from './schedules.service';
|
||||||
@@ -132,6 +132,23 @@ export class SchedulesController {
|
|||||||
@Body() dto: UpdateStopTimeDto,
|
@Body() dto: UpdateStopTimeDto,
|
||||||
) { return this.service.updateStop(id, sequence, dto); }
|
) { return this.service.updateStop(id, sequence, dto); }
|
||||||
|
|
||||||
|
@Put(':scheduleId/fares/:seatClassId')
|
||||||
|
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Override fare for a specific seat class on a schedule',
|
||||||
|
description: 'Upserts a schedule-scoped FareRule. Expires any existing active rule for the same schedule+seatClass and creates a new one.',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||||
|
@ApiParam({ name: 'seatClassId', description: 'SeatClass UUID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Fare rule upserted' })
|
||||||
|
upsertScheduleFare(
|
||||||
|
@Param('scheduleId') scheduleId: string,
|
||||||
|
@Param('seatClassId') seatClassId: string,
|
||||||
|
@Body() dto: { baseFareMinor: number; validFrom?: string; validUntil?: string },
|
||||||
|
) {
|
||||||
|
return this.service.upsertScheduleFare(scheduleId, seatClassId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':scheduleId/fares/stored')
|
@Get(':scheduleId/fares/stored')
|
||||||
@ApiOperation({ summary: 'Get stored fare rules for a schedule' })
|
@ApiOperation({ summary: 'Get stored fare rules for a schedule' })
|
||||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||||
|
|||||||
@@ -402,6 +402,34 @@ export class SchedulesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async upsertScheduleFare(
|
||||||
|
scheduleId: string,
|
||||||
|
seatClassId: string,
|
||||||
|
dto: { baseFareMinor: number; validFrom?: string; validUntil?: string },
|
||||||
|
) {
|
||||||
|
const [schedule, seatClass] = await Promise.all([
|
||||||
|
this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } }),
|
||||||
|
this.prisma.seatClass.findUnique({ where: { id: seatClassId } }),
|
||||||
|
]);
|
||||||
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
|
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const validFrom = dto.validFrom ? parseEthiopianTime(dto.validFrom) : now;
|
||||||
|
const validUntil = dto.validUntil ? parseEthiopianTime(dto.validUntil) : null;
|
||||||
|
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.fareRule.updateMany({
|
||||||
|
where: { tripId: scheduleId, seatClassId, validUntil: null },
|
||||||
|
data: { validUntil: now },
|
||||||
|
});
|
||||||
|
return tx.fareRule.create({
|
||||||
|
data: { tripId: scheduleId, seatClassId, baseFareMinor: dto.baseFareMinor, currency: 'ETB', validFrom, validUntil },
|
||||||
|
include: { seatClass: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
createFareRule(dto: CreateFareRuleDto) {
|
createFareRule(dto: CreateFareRuleDto) {
|
||||||
const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto;
|
const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto;
|
||||||
return this.prisma.fareRule.create({
|
return this.prisma.fareRule.create({
|
||||||
|
|||||||
@@ -418,6 +418,7 @@ export class SearchService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
|
if (!schedule.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation');
|
||||||
|
|
||||||
const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
|
const originStop = schedule.stopTimes.find((s: any) => s.stationId === dto.originStationId);
|
||||||
const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
|
const destStop = schedule.stopTimes.find((s: any) => s.stationId === dto.destinationStationId);
|
||||||
@@ -426,56 +427,25 @@ export class SearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: dto.seatClassName } });
|
const seatClass = await this.prisma.seatClass.findFirst({ where: { name: dto.seatClassName } });
|
||||||
|
if (!seatClass) throw new NotFoundException(`Seat class '${dto.seatClassName}' not found`);
|
||||||
|
|
||||||
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
const fare = await this.fareEngine.calculate({
|
||||||
const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`;
|
routeId: schedule.routeId,
|
||||||
const now = new Date();
|
originStationId: dto.originStationId,
|
||||||
const nationality = dto.nationality;
|
destinationStationId: dto.destinationStationId,
|
||||||
|
seatClassId: seatClass.id,
|
||||||
const candidates = await this.prisma.fareRule.findMany({
|
nationality: dto.nationality,
|
||||||
where: {
|
scheduleId: dto.scheduleId,
|
||||||
seatClassId: seatClass?.id,
|
adultCount: dto.adultCount,
|
||||||
validFrom: { lte: now },
|
childCount: dto.childCount ?? 0,
|
||||||
OR: [
|
promoCode: dto.promoCode,
|
||||||
{ validUntil: null },
|
|
||||||
{ validUntil: { gte: now } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const bestMatch = this.selectBestFareRule(
|
|
||||||
candidates,
|
|
||||||
dto.scheduleId,
|
|
||||||
segmentRoute,
|
|
||||||
fullRoute,
|
|
||||||
nationality,
|
|
||||||
);
|
|
||||||
|
|
||||||
const baseFareMinor = bestMatch?.baseFareMinor
|
|
||||||
?? await this.resolveScheduleFare(dto.scheduleId, seatClass?.id, dto.seatClassName);
|
|
||||||
|
|
||||||
const adultCount = dto.adultCount;
|
|
||||||
const childCount = dto.childCount ?? 0;
|
|
||||||
const adultFareMinor = baseFareMinor * adultCount;
|
|
||||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
|
||||||
const childFareMinor = baseFareMinor * paidChildrenCount;
|
|
||||||
const totalBaseFareMinor = adultFareMinor + childFareMinor;
|
|
||||||
|
|
||||||
let discountMinor = 0;
|
|
||||||
if (dto.promoCode) {
|
|
||||||
const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } });
|
|
||||||
if (promo?.active && promo.validUntil > now) {
|
|
||||||
discountMinor = promo.percentOff
|
|
||||||
? Math.round(totalBaseFareMinor * promo.percentOff / 100)
|
|
||||||
: (promo.amountOffMinor ?? 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR;
|
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * POINTS_TO_MINOR;
|
||||||
const taxesMinor = 0;
|
const totalMinor = Math.max(0, fare.totalMinor - loyaltyMinor);
|
||||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor);
|
|
||||||
|
|
||||||
const displayCurrency = dto.displayCurrency ?? resolveCurrencyFromNationality(dto.nationality);
|
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||||||
|
const displayCurrency = dto.displayCurrency ?? (fare.billingCurrency as Currency);
|
||||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||||
: totalMinor;
|
: totalMinor;
|
||||||
@@ -487,13 +457,23 @@ export class SearchService {
|
|||||||
segmentRoute,
|
segmentRoute,
|
||||||
seatClassName: dto.seatClassName,
|
seatClassName: dto.seatClassName,
|
||||||
nationality: dto.nationality,
|
nationality: dto.nationality,
|
||||||
adultCount, childCount,
|
adultCount: fare.adultCount,
|
||||||
baseFareMinor, adultFareMinor, childFareMinor,
|
childCount: fare.childCount,
|
||||||
freeChildrenCount: Math.min(childCount, 1),
|
baseFareMinor: fare.baseFarePerPassengerMinor,
|
||||||
paidChildrenCount, totalBaseFareMinor,
|
adultFareMinor: fare.adultCount * fare.farePerPassengerMinor,
|
||||||
discountMinor, loyaltyRedemptionMinor: loyaltyMinor,
|
childFareMinor: fare.paidChildrenCount * fare.farePerPassengerMinor,
|
||||||
taxesFeesMinor: taxesMinor, totalMinor,
|
freeChildrenCount: fare.freeChildrenCount,
|
||||||
currency: 'ETB', displayCurrency, displayTotalMinor,
|
paidChildrenCount: fare.paidChildrenCount,
|
||||||
|
premiumMinor: fare.premiumPerPassenger,
|
||||||
|
insuranceFeeMinor: fare.insurancePerPassenger,
|
||||||
|
totalBaseFareMinor: fare.subtotalMinor,
|
||||||
|
discountMinor: fare.discountMinor,
|
||||||
|
taxesFeesMinor: fare.taxMinor,
|
||||||
|
loyaltyRedemptionMinor: loyaltyMinor,
|
||||||
|
totalMinor,
|
||||||
|
currency: 'ETB',
|
||||||
|
displayCurrency,
|
||||||
|
displayTotalMinor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,15 +485,19 @@ export class SearchService {
|
|||||||
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
|
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
|
||||||
const displayCurrency = resolveCurrencyFromNationality(nationality);
|
const displayCurrency = resolveCurrencyFromNationality(nationality);
|
||||||
|
|
||||||
// Use seat class data already loaded in the schedule include — avoids an extra seatClass.findMany
|
// Collect seat class IDs from the schedule include for the ID set,
|
||||||
const seatClassMap = new Map<string, any>();
|
// but fetch fresh records from DB so updated baseFareMinor is always current
|
||||||
|
const seatClassIdSet = new Set<string>();
|
||||||
for (const a of schedule.coachAssignments) {
|
for (const a of schedule.coachAssignments) {
|
||||||
for (const sc of (a.coach.coachType?.seatClasses ?? [])) {
|
for (const sc of (a.coach.coachType?.seatClasses ?? [])) {
|
||||||
if (sc.isActive && !seatClassMap.has(sc.id)) seatClassMap.set(sc.id, sc);
|
if (sc.isActive) seatClassIdSet.add(sc.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const seatClasses = Array.from(seatClassMap.values())
|
const freshSeatClasses = await this.prisma.seatClass.findMany({
|
||||||
.sort((a: any, b: any) => a.baseFareMinor - b.baseFareMinor);
|
where: { id: { in: Array.from(seatClassIdSet) }, isActive: true },
|
||||||
|
});
|
||||||
|
const seatClassMap = new Map(freshSeatClasses.map(sc => [sc.id, sc]));
|
||||||
|
const seatClasses = freshSeatClasses.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
|
||||||
|
|
||||||
if (seatClasses.length === 0) return [];
|
if (seatClasses.length === 0) return [];
|
||||||
|
|
||||||
@@ -531,9 +515,9 @@ export class SearchService {
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
seatClassName: fare.seatClassName,
|
seatClassName: fare.seatClassName,
|
||||||
baseFareMinor: fare.baseFarePerPassengerMinor,
|
baseFareMinor: fare.totalMinor,
|
||||||
displayCurrency: fare.billingCurrency as Currency,
|
displayCurrency: fare.billingCurrency as Currency,
|
||||||
displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate),
|
displayAmountMinor: fare.totalInBillingCurrency,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -568,12 +552,16 @@ export class SearchService {
|
|||||||
|
|
||||||
if (fareRules.length > 0) {
|
if (fareRules.length > 0) {
|
||||||
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency);
|
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency);
|
||||||
return fareRules.map(rule => ({
|
const TAX_RATE = 0.05;
|
||||||
seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown',
|
return fareRules.map(rule => {
|
||||||
baseFareMinor: rule.baseFareMinor,
|
const totalMinor = rule.baseFareMinor + Math.round(rule.baseFareMinor * TAX_RATE);
|
||||||
displayCurrency,
|
return {
|
||||||
displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate),
|
seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown',
|
||||||
}));
|
baseFareMinor: totalMinor,
|
||||||
|
displayCurrency,
|
||||||
|
displayAmountMinor: Math.round(totalMinor * exchangeRate),
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,54 +632,4 @@ export class SearchService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveScheduleFare(scheduleId: string, seatClassId?: string, seatClassName?: string): Promise<number> {
|
|
||||||
if (!seatClassId) throw new NotFoundException(`Seat class '${seatClassName}' not found`);
|
|
||||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
|
||||||
where: { id: scheduleId },
|
|
||||||
select: { routeId: true, originStationId: true, destinationStationId: true },
|
|
||||||
});
|
|
||||||
if (!schedule?.routeId) throw new NotFoundException('Schedule has no route configured for fare calculation');
|
|
||||||
const fare = await this.fareEngine.calculate({
|
|
||||||
routeId: schedule.routeId,
|
|
||||||
originStationId: schedule.originStationId,
|
|
||||||
destinationStationId: schedule.destinationStationId,
|
|
||||||
seatClassId,
|
|
||||||
});
|
|
||||||
return fare.baseFarePerPassengerMinor;
|
|
||||||
}
|
|
||||||
|
|
||||||
private selectBestFareRule(
|
|
||||||
candidates: any[],
|
|
||||||
scheduleId: string,
|
|
||||||
segmentRoute: string,
|
|
||||||
fullRoute: string,
|
|
||||||
nationality?: string,
|
|
||||||
): any | null {
|
|
||||||
const priorities = [
|
|
||||||
{ tripId: scheduleId, route: segmentRoute, nationality },
|
|
||||||
{ tripId: scheduleId, route: segmentRoute, nationality: null },
|
|
||||||
{ tripId: scheduleId, route: fullRoute, nationality },
|
|
||||||
{ tripId: scheduleId, route: fullRoute, nationality: null },
|
|
||||||
{ tripId: scheduleId, route: null, nationality },
|
|
||||||
{ tripId: scheduleId, route: null, nationality: null },
|
|
||||||
{ tripId: null, route: segmentRoute, nationality },
|
|
||||||
{ tripId: null, route: segmentRoute, nationality: null },
|
|
||||||
{ tripId: null, route: fullRoute, nationality },
|
|
||||||
{ tripId: null, route: fullRoute, nationality: null },
|
|
||||||
{ tripId: null, route: null, nationality },
|
|
||||||
{ tripId: null, route: null, nationality: null },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const priority of priorities) {
|
|
||||||
const match = candidates.find(
|
|
||||||
(c) =>
|
|
||||||
c.tripId === priority.tripId &&
|
|
||||||
c.route === priority.route &&
|
|
||||||
c.nationality === priority.nationality,
|
|
||||||
);
|
|
||||||
if (match) return match;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,21 +19,31 @@ export class SeatClassesService {
|
|||||||
return sc;
|
return sc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateSeatClass(id: string, dto: any) {
|
||||||
|
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
|
||||||
|
if (!sc) throw new NotFoundException('SeatClass not found');
|
||||||
|
const { basePrice, ...rest } = dto;
|
||||||
|
const data = {
|
||||||
|
...rest,
|
||||||
|
...(basePrice !== undefined && { baseFareMinor: basePrice }),
|
||||||
|
};
|
||||||
|
return this.prisma.seatClass.update({ where: { id }, data });
|
||||||
|
}
|
||||||
|
|
||||||
async createSeatClass(dto: any) {
|
async createSeatClass(dto: any) {
|
||||||
try {
|
try {
|
||||||
return await this.prisma.seatClass.create({ data: dto });
|
const { basePrice, ...rest } = dto;
|
||||||
|
const data = {
|
||||||
|
...rest,
|
||||||
|
...(basePrice !== undefined && { baseFareMinor: basePrice }),
|
||||||
|
};
|
||||||
|
return await this.prisma.seatClass.create({ data });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`);
|
if (e.code === 'P2002') throw new ConflictException(`Seat class "${dto.name}" already exists`);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateSeatClass(id: string, dto: any) {
|
|
||||||
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
|
|
||||||
if (!sc) throw new NotFoundException('SeatClass not found');
|
|
||||||
return this.prisma.seatClass.update({ where: { id }, data: dto });
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteSeatClass(id: string) {
|
async deleteSeatClass(id: string) {
|
||||||
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
|
const sc = await this.prisma.seatClass.findUnique({ where: { id } });
|
||||||
if (!sc) throw new NotFoundException('SeatClass not found');
|
if (!sc) throw new NotFoundException('SeatClass not found');
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ export class TicketsService {
|
|||||||
} : null,
|
} : null,
|
||||||
status: t.status,
|
status: t.status,
|
||||||
validatedAt: t.validatedAt,
|
validatedAt: t.validatedAt,
|
||||||
|
boardedAt: t.validatedAt,
|
||||||
|
qrCode: t.qrPayload ?? null,
|
||||||
createdAt: t.issuedAt,
|
createdAt: t.issuedAt,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -247,9 +249,12 @@ export class TicketsService {
|
|||||||
// Use first seat for primary data
|
// Use first seat for primary data
|
||||||
const primarySeat = passengerSeats[0];
|
const primarySeat = passengerSeats[0];
|
||||||
|
|
||||||
// Build passenger QR data with all legs included
|
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`;
|
||||||
const qrData = JSON.stringify({
|
|
||||||
|
// Re-encode QR with ticketNumber included
|
||||||
|
const qrDataWithTicket = JSON.stringify({
|
||||||
ref: booking.bookingRef,
|
ref: booking.bookingRef,
|
||||||
|
ticketNumber: barcodePayload,
|
||||||
type: booking.bookingType,
|
type: booking.bookingType,
|
||||||
passenger: passengerName,
|
passenger: passengerName,
|
||||||
seats: passengerSeats.map(ps => ({
|
seats: passengerSeats.map(ps => ({
|
||||||
@@ -259,8 +264,7 @@ export class TicketsService {
|
|||||||
scheduleId: ps.scheduleId || booking.scheduleId,
|
scheduleId: ps.scheduleId || booking.scheduleId,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
const qrPayload = await QRCode.toDataURL(qrData);
|
const qrPayloadFinal = await QRCode.toDataURL(qrDataWithTicket);
|
||||||
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`;
|
|
||||||
|
|
||||||
const ticket = await this.prisma.ticket.create({
|
const ticket = await this.prisma.ticket.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -270,7 +274,7 @@ export class TicketsService {
|
|||||||
seatId: primarySeat.seatId,
|
seatId: primarySeat.seatId,
|
||||||
leg: primarySeat.leg || 1,
|
leg: primarySeat.leg || 1,
|
||||||
scheduleId: primarySeat.scheduleId || booking.scheduleId,
|
scheduleId: primarySeat.scheduleId || booking.scheduleId,
|
||||||
qrPayload,
|
qrPayload: qrPayloadFinal,
|
||||||
barcodePayload,
|
barcodePayload,
|
||||||
} as any,
|
} as any,
|
||||||
});
|
});
|
||||||
@@ -371,15 +375,13 @@ export class TicketsService {
|
|||||||
let bookingRef = qrCodeOrRef;
|
let bookingRef = qrCodeOrRef;
|
||||||
try {
|
try {
|
||||||
const qrData = JSON.parse(qrCodeOrRef);
|
const qrData = JSON.parse(qrCodeOrRef);
|
||||||
if (qrData.ref) {
|
if (qrData.ref) bookingRef = qrData.ref;
|
||||||
bookingRef = qrData.ref;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Not JSON, treat as booking reference
|
// Not JSON, treat as booking reference or ticket number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get booking and ticket info
|
// Get booking and ticket info
|
||||||
const booking = await this.prisma.booking.findUnique({
|
let booking = await this.prisma.booking.findUnique({
|
||||||
where: { bookingRef },
|
where: { bookingRef },
|
||||||
include: {
|
include: {
|
||||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||||
@@ -389,6 +391,23 @@ export class TicketsService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
// Input may be a ticket number (barcodePayload) — look it up
|
||||||
|
const ticket = await this.prisma.ticket.findFirst({ where: { barcodePayload: bookingRef } });
|
||||||
|
if (ticket) {
|
||||||
|
booking = await this.prisma.booking.findUnique({
|
||||||
|
where: { bookingRef: ticket.bookingRef },
|
||||||
|
include: {
|
||||||
|
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||||
|
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
||||||
|
tickets: true,
|
||||||
|
seats: { include: { seat: { include: { coach: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (booking) bookingRef = (booking as any).bookingRef;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
throw new NotFoundException('Ticket not found');
|
throw new NotFoundException('Ticket not found');
|
||||||
}
|
}
|
||||||
@@ -443,6 +462,7 @@ export class TicketsService {
|
|||||||
message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`,
|
message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`,
|
||||||
boarding: {
|
boarding: {
|
||||||
ticketId: ticket.id,
|
ticketId: ticket.id,
|
||||||
|
ticketNumber: ticket.barcodePayload,
|
||||||
bookingRef: booking.bookingRef,
|
bookingRef: booking.bookingRef,
|
||||||
passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A',
|
passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A',
|
||||||
route: `${(booking as any).schedule?.originStation?.name || 'N/A'} → ${(booking as any).schedule?.destinationStation?.name || 'N/A'}`,
|
route: `${(booking as any).schedule?.originStation?.name || 'N/A'} → ${(booking as any).schedule?.destinationStation?.name || 'N/A'}`,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
|||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const [isScanning, setIsScanning] = useState(false);
|
const [isScanning, setIsScanning] = useState(false);
|
||||||
const [isInitializing, setIsInitializing] = useState(false);
|
const [isInitializing, setIsInitializing] = useState(false);
|
||||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
const streamRef = useRef<MediaStream | null>(null);
|
||||||
const [cameraError, setCameraError] = useState<string | null>(null);
|
const [cameraError, setCameraError] = useState<string | null>(null);
|
||||||
const scanIntervalRef = useRef<number | null>(null);
|
const scanIntervalRef = useRef<number | null>(null);
|
||||||
|
|
||||||
@@ -34,9 +34,9 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// First, stop any existing stream
|
// First, stop any existing stream
|
||||||
if (stream) {
|
if (streamRef.current) {
|
||||||
stream.getTracks().forEach(track => track.stop());
|
streamRef.current.getTracks().forEach(track => track.stop());
|
||||||
setStream(null);
|
streamRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request camera access with simpler fallback
|
// Request camera access with simpler fallback
|
||||||
@@ -120,7 +120,7 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set state to show video
|
// Set state to show video
|
||||||
setStream(mediaStream);
|
streamRef.current = mediaStream;
|
||||||
setIsScanning(true);
|
setIsScanning(true);
|
||||||
setIsInitializing(false);
|
setIsInitializing(false);
|
||||||
|
|
||||||
@@ -144,9 +144,9 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
|||||||
onError(errorMsg);
|
onError(errorMsg);
|
||||||
|
|
||||||
// Clean up on error
|
// Clean up on error
|
||||||
if (stream) {
|
if (streamRef.current) {
|
||||||
stream.getTracks().forEach(track => track.stop());
|
streamRef.current.getTracks().forEach(track => track.stop());
|
||||||
setStream(null);
|
streamRef.current = null;
|
||||||
}
|
}
|
||||||
setIsScanning(false);
|
setIsScanning(false);
|
||||||
setIsInitializing(false);
|
setIsInitializing(false);
|
||||||
@@ -158,16 +158,16 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
|||||||
clearInterval(scanIntervalRef.current);
|
clearInterval(scanIntervalRef.current);
|
||||||
scanIntervalRef.current = null;
|
scanIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
if (stream) {
|
if (streamRef.current) {
|
||||||
stream.getTracks().forEach(track => track.stop());
|
streamRef.current.getTracks().forEach(track => track.stop());
|
||||||
setStream(null);
|
streamRef.current = null;
|
||||||
}
|
}
|
||||||
if (videoRef.current) {
|
if (videoRef.current) {
|
||||||
videoRef.current.srcObject = null;
|
videoRef.current.srcObject = null;
|
||||||
}
|
}
|
||||||
setIsScanning(false);
|
setIsScanning(false);
|
||||||
setCameraError(null);
|
setCameraError(null);
|
||||||
}, [stream]);
|
}, []);
|
||||||
|
|
||||||
// QR code scanning with jsqr
|
// QR code scanning with jsqr
|
||||||
const scanFrame = useCallback(() => {
|
const scanFrame = useCallback(() => {
|
||||||
@@ -201,15 +201,19 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isScanning) {
|
if (isScanning) {
|
||||||
scanIntervalRef.current = window.setInterval(scanFrame, 100); // Scan every 100ms
|
scanIntervalRef.current = window.setInterval(scanFrame, 100);
|
||||||
}
|
} else {
|
||||||
return () => {
|
|
||||||
if (scanIntervalRef.current) {
|
if (scanIntervalRef.current) {
|
||||||
clearInterval(scanIntervalRef.current);
|
clearInterval(scanIntervalRef.current);
|
||||||
|
scanIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
stopCamera();
|
}
|
||||||
};
|
}, [isScanning, scanFrame]);
|
||||||
}, [isScanning, scanFrame, stopCamera]);
|
|
||||||
|
// Cleanup on unmount only
|
||||||
|
useEffect(() => {
|
||||||
|
return () => stopCamera();
|
||||||
|
}, [stopCamera]);
|
||||||
|
|
||||||
// Load jsqr from CDN
|
// Load jsqr from CDN
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -294,9 +298,9 @@ function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onErro
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (stream) {
|
if (streamRef.current) {
|
||||||
stream.getTracks().forEach(track => track.stop());
|
streamRef.current.getTracks().forEach(track => track.stop());
|
||||||
setStream(null);
|
streamRef.current = null;
|
||||||
}
|
}
|
||||||
setIsInitializing(false);
|
setIsInitializing(false);
|
||||||
setCameraError('Camera initialization cancelled by user');
|
setCameraError('Camera initialization cancelled by user');
|
||||||
@@ -348,7 +352,7 @@ export default function BoardingPage() {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
setSuccess('Passenger boarded successfully!');
|
setSuccess('Passenger boarded successfully!');
|
||||||
setLastScanned(result.boarding);
|
setLastScanned(result.boarding);
|
||||||
setQrInput('');
|
setQrInput(result.boarding?.ticketNumber || result.boarding?.bookingRef || '');
|
||||||
// Auto-focus for next scan
|
// Auto-focus for next scan
|
||||||
setTimeout(() => inputRef.current?.focus(), 1000);
|
setTimeout(() => inputRef.current?.focus(), 1000);
|
||||||
} else {
|
} else {
|
||||||
@@ -442,7 +446,13 @@ export default function BoardingPage() {
|
|||||||
{/* Camera Scanner */}
|
{/* Camera Scanner */}
|
||||||
<QRScanner
|
<QRScanner
|
||||||
onScan={(data) => {
|
onScan={(data) => {
|
||||||
setQrInput(data);
|
let displayValue = data;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
if (parsed.ticketNumber) displayValue = parsed.ticketNumber;
|
||||||
|
else if (parsed.ref) displayValue = parsed.ref;
|
||||||
|
} catch { /* not JSON, use raw */ }
|
||||||
|
setQrInput(displayValue);
|
||||||
handleScan(data);
|
handleScan(data);
|
||||||
}}
|
}}
|
||||||
onError={setError}
|
onError={setError}
|
||||||
@@ -537,7 +547,7 @@ export default function BoardingPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="text-sm text-green-600 dark:text-green-400 font-mono mt-2">
|
<div className="text-sm text-green-600 dark:text-green-400 font-mono mt-2">
|
||||||
Booking: {lastScanned.bookingRef} | Ticket: {lastScanned.ticketId}
|
Booking: {lastScanned.bookingRef} | Ticket: {lastScanned.ticketNumber}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-green-600 dark:text-green-400 mt-2">
|
<div className="text-xs text-green-600 dark:text-green-400 mt-2">
|
||||||
@@ -565,7 +575,7 @@ export default function BoardingPage() {
|
|||||||
<li>• Tap "Scan QR Code" and point at ticket QR code</li>
|
<li>• Tap "Scan QR Code" and point at ticket QR code</li>
|
||||||
<li>• Allow camera access when your browser prompts you</li>
|
<li>• Allow camera access when your browser prompts you</li>
|
||||||
<li>• Hold phone steady and position QR code within the frame</li>
|
<li>• Hold phone steady and position QR code within the frame</li>
|
||||||
<li>• For manual option, type or paste booking reference</li>
|
<li>• For manual option, type or paste ticket number</li>
|
||||||
<li>• Tickets can only be boarded on their departure date</li>
|
<li>• Tickets can only be boarded on their departure date</li>
|
||||||
<li>• First scan boards outbound leg for round trips</li>
|
<li>• First scan boards outbound leg for round trips</li>
|
||||||
<li>• Email & SMS sent automatically to passenger contacts</li>
|
<li>• Email & SMS sent automatically to passenger contacts</li>
|
||||||
|
|||||||
@@ -75,9 +75,9 @@ export default function ClassesPage() {
|
|||||||
coachTypeId: selectedCoachTypeId,
|
coachTypeId: selectedCoachTypeId,
|
||||||
name: formData.get('name') as string,
|
name: formData.get('name') as string,
|
||||||
description: formData.get('description') as string,
|
description: formData.get('description') as string,
|
||||||
baseFareMinor: Math.round(parseFloat(formData.get('baseFareMinor') as string) * 100) || 0,
|
baseFareMinor: Math.round(Number((parseFloat(formData.get('baseFareMinor') as string) * 100).toFixed(10))) || 0,
|
||||||
premiumMinor: Math.round(parseFloat(formData.get('premiumMinor') as string) * 100) || 0,
|
premiumMinor: Math.round(Number((parseFloat(formData.get('premiumMinor') as string) * 100).toFixed(10))) || 0,
|
||||||
insuranceFeeMinor: Math.round(parseFloat(formData.get('insuranceFeeMinor') as string) * 100) || 0,
|
insuranceFeeMinor: Math.round(Number((parseFloat(formData.get('insuranceFeeMinor') as string) * 100).toFixed(10))) || 0,
|
||||||
isActive: formData.get('isActive') === 'true',
|
isActive: formData.get('isActive') === 'true',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,13 +129,6 @@ export default function ClassesPage() {
|
|||||||
label: 'Class Name',
|
label: 'Class Name',
|
||||||
render: (cls: any) => <span className="font-medium">{cls.name}</span>,
|
render: (cls: any) => <span className="font-medium">{cls.name}</span>,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'description',
|
|
||||||
label: 'Description',
|
|
||||||
render: (cls: any) => (
|
|
||||||
<span className="text-sm text-muted-foreground">{cls.description || '-'}</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'baseFareMinor',
|
key: 'baseFareMinor',
|
||||||
label: 'Base Fare',
|
label: 'Base Fare',
|
||||||
@@ -251,7 +244,7 @@ export default function ClassesPage() {
|
|||||||
title={`${editingClass ? 'Edit' : 'Add'} Class`}
|
title={`${editingClass ? 'Edit' : 'Add'} Class`}
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form key={editingClass?.id ?? 'new'} onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Coach Type *</label>
|
<label className="label">Coach Type *</label>
|
||||||
@@ -265,7 +258,7 @@ export default function ClassesPage() {
|
|||||||
<option value="">Select Coach Type</option>
|
<option value="">Select Coach Type</option>
|
||||||
{coachTypesArray.map((ct: any) => (
|
{coachTypesArray.map((ct: any) => (
|
||||||
<option key={ct.id} value={ct.id}>
|
<option key={ct.id} value={ct.id}>
|
||||||
{ct.code} - {ct.name}
|
{ct.code} - {ct.name} - {ct.type}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -283,17 +276,6 @@ export default function ClassesPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="label">Description</label>
|
|
||||||
<textarea
|
|
||||||
name="description"
|
|
||||||
className="input"
|
|
||||||
rows={3}
|
|
||||||
defaultValue={editingClass?.description || ''}
|
|
||||||
placeholder="Describe this class..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<h3 className="font-semibold text-foreground mb-4">Pricing Configuration</h3>
|
<h3 className="font-semibold text-foreground mb-4">Pricing Configuration</h3>
|
||||||
|
|
||||||
@@ -345,8 +327,8 @@ export default function ClassesPage() {
|
|||||||
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-sm text-blue-800 dark:text-blue-200">
|
<div className="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-sm text-blue-800 dark:text-blue-200">
|
||||||
<p className="font-medium mb-1">Total Fare Calculation:</p>
|
<p className="font-medium mb-1">Total Fare Calculation:</p>
|
||||||
<p>Total = (Base Fare × Distance) + Premium + Insurance</p>
|
<p>Total = (Base Fare × Distance) + Premium + Insurance</p>
|
||||||
<p className="mt-2 text-xs">• Premium applies per passenger (including free child)</p>
|
<p className="mt-2 text-xs">• Premium applies per passenger</p>
|
||||||
<p className="text-xs">• Insurance applies per passenger (including free child)</p>
|
<p className="text-xs">• Insurance applies per passenger</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -731,7 +731,7 @@ export default function CoachesPage() {
|
|||||||
<option value="">Select Coach Type</option>
|
<option value="">Select Coach Type</option>
|
||||||
{coachTypesArray.map((ct: any) => (
|
{coachTypesArray.map((ct: any) => (
|
||||||
<option key={ct.id} value={ct.id}>
|
<option key={ct.id} value={ct.id}>
|
||||||
{ct.code} - {ct.name}
|
{ct.code} - {ct.name} - {ct.type}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ interface SeatClass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function PricingPage() {
|
export default function PricingPage() {
|
||||||
const [tab, setTab] = useState<'schedule' | 'segment' | 'baggage'>('schedule');
|
const [tab, setTab] = useState<'segment' | 'schedule' | 'baggage'>('segment');
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [selectedSchedule, setSelectedSchedule] = useState<string>('');
|
const [selectedSchedule, setSelectedSchedule] = useState<string>('');
|
||||||
const [selectedRoute, setSelectedRoute] = useState<string>('');
|
const [selectedRoute, setSelectedRoute] = useState<string>('');
|
||||||
@@ -103,7 +103,7 @@ export default function PricingPage() {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!selectedSchedule) return [];
|
if (!selectedSchedule) return [];
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get(`/schedules/${selectedSchedule}/fares/all`);
|
const response = await apiClient.get(`/schedules/${selectedSchedule}/fares/stored`);
|
||||||
return Array.isArray(response) ? response : (response as any)?.data || [];
|
return Array.isArray(response) ? response : (response as any)?.data || [];
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errMsg = err.response?.data?.message || err.message || 'Failed to load fares';
|
const errMsg = err.response?.data?.message || err.message || 'Failed to load fares';
|
||||||
@@ -175,7 +175,7 @@ export default function PricingPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateFareMutation = useMutation({
|
const updateFareMutation = useMutation({
|
||||||
mutationFn: (data: any) => apiClient.patch(`/schedules/fares/${data.id}`, data),
|
mutationFn: ({ id, ...data }: any) => apiClient.patch(`/schedules/fares/${id}`, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
refetchFares();
|
refetchFares();
|
||||||
setEditingFare(null);
|
setEditingFare(null);
|
||||||
@@ -264,10 +264,12 @@ export default function PricingPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEditFare = (fare: any) => {
|
const handleEditFare = (fare: any) => {
|
||||||
setEditingFare(fare);
|
// Engine-calculated fares have no `id` — open as new rule pre-filled with engine values
|
||||||
|
setEditingFare(fare.id ? fare : null);
|
||||||
|
const minor = fare.totalMinor ?? fare.baseFareMinor ?? fare.baseFare ?? 0;
|
||||||
setFareForm({
|
setFareForm({
|
||||||
seatClassId: fare.seatClassId || '',
|
seatClassId: fare.seatClassId || '',
|
||||||
baseFare: (fare.baseFare || fare.baseFareMinor || 0).toString(),
|
baseFare: (minor / 100).toFixed(2),
|
||||||
nationality: fare.nationality || '',
|
nationality: fare.nationality || '',
|
||||||
passengerCategory: fare.passengerCategory || '',
|
passengerCategory: fare.passengerCategory || '',
|
||||||
route: fare.route || '',
|
route: fare.route || '',
|
||||||
@@ -283,12 +285,12 @@ export default function PricingPage() {
|
|||||||
const routeStops = currentRoute?.stops || [];
|
const routeStops = currentRoute?.stops || [];
|
||||||
const originStop = routeStops.find((s: any) => s.sequence === fare.originStopSequence);
|
const originStop = routeStops.find((s: any) => s.sequence === fare.originStopSequence);
|
||||||
const destStop = routeStops.find((s: any) => s.sequence === fare.destinationStopSequence);
|
const destStop = routeStops.find((s: any) => s.sequence === fare.destinationStopSequence);
|
||||||
|
|
||||||
setSegmentForm({
|
setSegmentForm({
|
||||||
seatClassId: fare.seatClassId || '',
|
seatClassId: fare.seatClassId || '',
|
||||||
originStationId: originStop?.stationId || '',
|
originStationId: originStop?.stationId || '',
|
||||||
destinationStationId: destStop?.stationId || '',
|
destinationStationId: destStop?.stationId || '',
|
||||||
baseFare: (fare.baseFare || fare.baseFareMinor || 0).toString(),
|
baseFare: ((fare.baseFare || fare.baseFareMinor || 0) / 100).toFixed(2),
|
||||||
nationality: fare.nationality || '',
|
nationality: fare.nationality || '',
|
||||||
passengerCategory: fare.passengerCategory || '',
|
passengerCategory: fare.passengerCategory || '',
|
||||||
validFrom: fare.validFrom ? new Date(fare.validFrom).toISOString().split('T')[0] : new Date().toISOString().split('T')[0],
|
validFrom: fare.validFrom ? new Date(fare.validFrom).toISOString().split('T')[0] : new Date().toISOString().split('T')[0],
|
||||||
@@ -305,7 +307,7 @@ export default function PricingPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseFareMinor = parseInt(fareForm.baseFare, 10);
|
const baseFareMinor = Math.round(parseFloat(fareForm.baseFare) * 100);
|
||||||
|
|
||||||
if (editingFare) {
|
if (editingFare) {
|
||||||
await updateFareMutation.mutateAsync({
|
await updateFareMutation.mutateAsync({
|
||||||
@@ -353,7 +355,7 @@ export default function PricingPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseFareMinor = parseInt(segmentForm.baseFare, 10);
|
const baseFareMinor = Math.round(parseFloat(segmentForm.baseFare) * 100);
|
||||||
|
|
||||||
if (editingFare) {
|
if (editingFare) {
|
||||||
await updateSegmentFareMutation.mutateAsync({
|
await updateSegmentFareMutation.mutateAsync({
|
||||||
@@ -422,9 +424,9 @@ export default function PricingPage() {
|
|||||||
key: 'baseFare',
|
key: 'baseFare',
|
||||||
label: 'Fare (ETB)',
|
label: 'Fare (ETB)',
|
||||||
render: (fare: any) => {
|
render: (fare: any) => {
|
||||||
const fareValue = fare.baseFare || fare.baseFareMinor;
|
const minor = fare.baseFareMinor ?? fare.baseFare;
|
||||||
if (!fareValue && fareValue !== 0) return <span>N/A</span>;
|
if (minor == null) return <span className="text-muted-foreground">—</span>;
|
||||||
return <span className="font-mono font-medium">{fareValue} ETB</span>;
|
return <span className="font-mono font-medium">{(minor / 100).toFixed(2)} </span>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -438,7 +440,7 @@ export default function PricingPage() {
|
|||||||
key: 'route',
|
key: 'route',
|
||||||
label: 'Route',
|
label: 'Route',
|
||||||
render: (fare: any) => (
|
render: (fare: any) => (
|
||||||
<span className="text-sm font-mono">{fare.route || '-'}</span>
|
<span className="text-sm font-mono">{fare.routeCode || fare.route || '-'}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -467,9 +469,11 @@ export default function PricingPage() {
|
|||||||
const stops = currentRoute?.stops || [];
|
const stops = currentRoute?.stops || [];
|
||||||
const originStop = stops.find((s: any) => s.sequence === fare.originStopSequence);
|
const originStop = stops.find((s: any) => s.sequence === fare.originStopSequence);
|
||||||
const destStop = stops.find((s: any) => s.sequence === fare.destinationStopSequence);
|
const destStop = stops.find((s: any) => s.sequence === fare.destinationStopSequence);
|
||||||
|
const originCode = stationsArray.find((s: any) => s.id === originStop?.stationId)?.code ?? `Stop ${fare.originStopSequence}`;
|
||||||
|
const destCode = stationsArray.find((s: any) => s.id === destStop?.stationId)?.code ?? `Stop ${fare.destinationStopSequence}`;
|
||||||
return (
|
return (
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium font-mono">
|
||||||
Stop {fare.originStopSequence} → {fare.destinationStopSequence}
|
{originCode} → {destCode}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -479,7 +483,7 @@ export default function PricingPage() {
|
|||||||
label: 'Seat Class',
|
label: 'Seat Class',
|
||||||
render: (fare: any) => {
|
render: (fare: any) => {
|
||||||
const className = fare.seatClass?.name || 'N/A';
|
const className = fare.seatClass?.name || 'N/A';
|
||||||
return <span className="font-medium">{className}</span>;
|
return <span>{className}</span>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -493,9 +497,9 @@ export default function PricingPage() {
|
|||||||
key: 'baseFare',
|
key: 'baseFare',
|
||||||
label: 'Fare (ETB)',
|
label: 'Fare (ETB)',
|
||||||
render: (fare: any) => {
|
render: (fare: any) => {
|
||||||
const fareValue = fare.baseFare || fare.baseFareMinor;
|
const fareValue = fare.baseFare ?? fare.baseFareMinor;
|
||||||
if (!fareValue && fareValue !== 0) return <span>N/A</span>;
|
if (fareValue == null) return <span className="text-muted-foreground">—</span>;
|
||||||
return <span className="font-mono font-medium">{fareValue} ETB</span>;
|
return <span className="font-mono font-medium">{(fareValue / 100).toFixed(2)} </span>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -529,14 +533,12 @@ export default function PricingPage() {
|
|||||||
onClick: tab === 'schedule' ? handleEditFare : handleEditSegmentFare,
|
onClick: tab === 'schedule' ? handleEditFare : handleEditSegmentFare,
|
||||||
variant: 'secondary' as const,
|
variant: 'secondary' as const,
|
||||||
icon: Edit,
|
icon: Edit,
|
||||||
disabled: tab === 'schedule', // Schedule fares are computed, not stored
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Delete',
|
label: 'Delete',
|
||||||
onClick: (fare: any) => setDeleteConfirm({ isOpen: true, id: fare.id }),
|
onClick: (fare: any) => setDeleteConfirm({ isOpen: true, id: fare.id }),
|
||||||
variant: 'danger' as const,
|
variant: 'danger' as const,
|
||||||
icon: Trash2,
|
icon: Trash2,
|
||||||
disabled: tab === 'schedule', // Schedule fares are computed, not stored
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -588,89 +590,30 @@ export default function PricingPage() {
|
|||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex gap-4 border-b mb-6">
|
<div className="flex gap-4 border-b mb-6">
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setTab('schedule');
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
|
|
||||||
tab === 'schedule' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Schedule Fares
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { setTab('segment'); setError(null); }}
|
onClick={() => { setTab('segment'); setError(null); }}
|
||||||
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
|
className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === 'segment' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
|
||||||
tab === 'segment' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
Segment Fares
|
Segment Fares
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setTab('schedule'); setError(null); }}
|
||||||
|
className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === 'schedule' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Schedule Fares
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setTab('baggage'); setError(null); }}
|
onClick={() => { setTab('baggage'); setError(null); }}
|
||||||
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
|
className={`px-4 py-2 font-medium border-b-2 transition-colors ${tab === 'baggage' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
|
||||||
tab === 'baggage' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
Excess Baggage Rates
|
Excess Baggage Rates
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{tab === 'schedule' && (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="label">Select Schedule</label>
|
|
||||||
<select
|
|
||||||
value={selectedSchedule}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSelectedSchedule(e.target.value);
|
|
||||||
setError(null);
|
|
||||||
}}
|
|
||||||
className="input w-full max-w-md"
|
|
||||||
>
|
|
||||||
<option value="">Choose a schedule...</option>
|
|
||||||
{schedulesArray.map((schedule: Schedule) => (
|
|
||||||
<option key={schedule.id} value={schedule.id}>
|
|
||||||
{schedule.train?.name} ({schedule.train?.number}) - {schedule.originStation?.name} to {schedule.destinationStation?.name} ({new Date(schedule.departureAt).toLocaleDateString()})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedSchedule && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold mb-4">Calculated Fares</h3>
|
|
||||||
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-sm text-blue-800 dark:text-blue-200">
|
|
||||||
These are <strong>dynamically calculated</strong> fares based on the fare engine. To create custom override fares, click "Add Fare Rule" above.
|
|
||||||
</div>
|
|
||||||
{faresLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : faresArray.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
|
||||||
No fares available for this schedule.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="mb-4 p-3 bg-muted/50 rounded text-sm text-muted-foreground">
|
|
||||||
{faresArray.length} seat class(es) available
|
|
||||||
</div>
|
|
||||||
<DataTable
|
|
||||||
data={faresArray}
|
|
||||||
columns={fareColumns}
|
|
||||||
actions={fareActions}
|
|
||||||
loading={false}
|
|
||||||
emptyMessage="No fares available."
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'segment' && (
|
{tab === 'segment' && (
|
||||||
<>
|
<>
|
||||||
@@ -722,6 +665,61 @@ export default function PricingPage() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === 'schedule' && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="label">Select Schedule</label>
|
||||||
|
<select
|
||||||
|
value={selectedSchedule}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedSchedule(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="input w-full max-w-md"
|
||||||
|
>
|
||||||
|
<option value="">Choose a schedule...</option>
|
||||||
|
{schedulesArray.map((schedule: Schedule) => (
|
||||||
|
<option key={schedule.id} value={schedule.id}>
|
||||||
|
{schedule.train?.name} ({schedule.train?.number}) - {schedule.originStation?.name} to {schedule.destinationStation?.name} ({new Date(schedule.departureAt).toLocaleDateString()})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedSchedule && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Stored Fare Rules</h3>
|
||||||
|
{faresLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : faresArray.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No fare rules defined for this schedule. Click "Add Fare Rule" to create one.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
These are <strong>stored fare override rules</strong> for this schedule. Click "Add Fare Rule" to create one. If no rules exist, the fare engine calculates fares automatically.
|
||||||
|
</div>
|
||||||
|
<div className="mb-4 p-3 bg-muted/50 rounded text-sm text-muted-foreground">
|
||||||
|
{faresArray.length} fare rule(s) defined
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
data={faresArray}
|
||||||
|
columns={fareColumns}
|
||||||
|
actions={fareActions}
|
||||||
|
loading={false}
|
||||||
|
emptyMessage="No fares available."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{tab === 'baggage' && (
|
{tab === 'baggage' && (
|
||||||
<>
|
<>
|
||||||
{allowancesLoading ? (
|
{allowancesLoading ? (
|
||||||
@@ -736,7 +734,7 @@ export default function PricingPage() {
|
|||||||
columns={[
|
columns={[
|
||||||
{ key: 'seatClass', label: 'Seat Class', render: (a: any) => <span className="font-medium">{a.seatClass?.name ?? a.seatClassId}</span> },
|
{ key: 'seatClass', label: 'Seat Class', render: (a: any) => <span className="font-medium">{a.seatClass?.name ?? a.seatClassId}</span> },
|
||||||
{ key: 'maxWeightKg', label: 'Free Allowance', render: (a: any) => <span>{a.maxWeightKg} kg, {a.maxPiecesCount} pcs</span> },
|
{ key: 'maxWeightKg', label: 'Free Allowance', render: (a: any) => <span>{a.maxWeightKg} kg, {a.maxPiecesCount} pcs</span> },
|
||||||
{ key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: any) => <span className="font-mono font-semibold">{(a.excessFeePerKg / 100).toFixed(2)} ETB</span> },
|
{ key: 'excessFeePerKg', label: 'Excess Fee / kg', render: (a: any) => <span className="font-mono font-semibold">{(a.excessFeePerKg / 100).toFixed(2)} </span> },
|
||||||
]}
|
]}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
@@ -771,10 +769,10 @@ export default function PricingPage() {
|
|||||||
<h3 className="font-semibold text-blue-900 dark:text-blue-200 mb-3">Pricing Structure</h3>
|
<h3 className="font-semibold text-blue-900 dark:text-blue-200 mb-3">Pricing Structure</h3>
|
||||||
<ul className="text-sm text-blue-800 dark:text-blue-300 space-y-2">
|
<ul className="text-sm text-blue-800 dark:text-blue-300 space-y-2">
|
||||||
<li>
|
<li>
|
||||||
• <strong>Schedule Fares:</strong> Set custom pricing for each schedule by seat class and passenger type
|
• <strong>Segment Fares:</strong> Set fares for specific stop-to-stop segments (e.g., Addis → Dire Dawa)
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
• <strong>Segment Fares:</strong> Set fares for specific stop-to-stop segments (e.g., Addis → Dire Dawa)
|
• <strong>Schedule Fares:</strong> Set custom pricing for each schedule by seat class and passenger type
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
• <strong>Passenger Type:</strong> ADULT (5+ years) or CHILD (<5) — first child travels free, subsequent children pay full fare
|
• <strong>Passenger Type:</strong> ADULT (5+ years) or CHILD (<5) — first child travels free, subsequent children pay full fare
|
||||||
@@ -876,11 +874,11 @@ export default function PricingPage() {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="0.01"
|
||||||
value={fareForm.baseFare}
|
value={fareForm.baseFare}
|
||||||
onChange={(e) => setFareForm({ ...fareForm, baseFare: e.target.value })}
|
onChange={(e) => setFareForm({ ...fareForm, baseFare: e.target.value })}
|
||||||
className="input w-full"
|
className="input w-full"
|
||||||
placeholder="e.g., 350"
|
placeholder="e.g., 350.00"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1006,11 +1004,11 @@ export default function PricingPage() {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="0.01"
|
||||||
value={segmentForm.baseFare}
|
value={segmentForm.baseFare}
|
||||||
onChange={(e) => setSegmentForm({ ...segmentForm, baseFare: e.target.value })}
|
onChange={(e) => setSegmentForm({ ...segmentForm, baseFare: e.target.value })}
|
||||||
className="input w-full"
|
className="input w-full"
|
||||||
placeholder="e.g., 150"
|
placeholder="e.g., 150.00"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1092,8 +1090,8 @@ export default function PricingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 justify-end pt-4">
|
<div className="flex gap-2 justify-end pt-4">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (tab === 'schedule') resetForm();
|
if (tab === 'schedule') resetForm();
|
||||||
else resetSegmentForm();
|
else resetSegmentForm();
|
||||||
@@ -1102,8 +1100,8 @@ export default function PricingPage() {
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
onClick={tab === 'schedule' ? handleSaveFare : handleSaveSegmentFare}
|
onClick={tab === 'schedule' ? handleSaveFare : handleSaveSegmentFare}
|
||||||
loading={
|
loading={
|
||||||
tab === 'schedule'
|
tab === 'schedule'
|
||||||
? createFareMutation.isPending || updateFareMutation.isPending
|
? createFareMutation.isPending || updateFareMutation.isPending
|
||||||
|
|||||||
@@ -84,21 +84,18 @@ export default function RoutesPage() {
|
|||||||
// Keep current stop order (already rearranged by user)
|
// Keep current stop order (already rearranged by user)
|
||||||
const sortedMiddleStops = stops;
|
const sortedMiddleStops = stops;
|
||||||
|
|
||||||
// Calculate distanceKm (distance from previous stop)
|
// distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm)
|
||||||
const stopsArray = [
|
const stopsArray = [
|
||||||
{ stationId: originStationId, sequence: 1, distanceKm: 0 },
|
{ stationId: originStationId, sequence: 1, distanceKm: 0 },
|
||||||
...sortedMiddleStops.map((stop, idx) => {
|
...sortedMiddleStops.map((stop, idx) => ({
|
||||||
const prevDistance = idx === 0 ? 0 : (sortedMiddleStops[idx - 1].distanceFromOrigin || 0);
|
stationId: stop.stationId,
|
||||||
return {
|
sequence: idx + 2,
|
||||||
stationId: stop.stationId,
|
distanceKm: stop.distanceFromOrigin || 0,
|
||||||
sequence: idx + 2,
|
})),
|
||||||
distanceKm: (stop.distanceFromOrigin || 0) - prevDistance,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
{
|
{
|
||||||
stationId: destinationStationId,
|
stationId: destinationStationId,
|
||||||
sequence: sortedMiddleStops.length + 2,
|
sequence: sortedMiddleStops.length + 2,
|
||||||
distanceKm: (destinationDistance || 0) - (sortedMiddleStops.length > 0 ? (sortedMiddleStops[sortedMiddleStops.length - 1].distanceFromOrigin || 0) : 0),
|
distanceKm: destinationDistance || 0,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -218,19 +215,14 @@ export default function RoutesPage() {
|
|||||||
setOriginStationId(routeStops[0].stationId);
|
setOriginStationId(routeStops[0].stationId);
|
||||||
setDestinationStationId(routeStops[routeStops.length - 1].stationId);
|
setDestinationStationId(routeStops[routeStops.length - 1].stationId);
|
||||||
|
|
||||||
// Last stop's distanceKm is segment distance from previous stop, so accumulate
|
// distanceKm is cumulative from origin — read directly
|
||||||
let cumulative = 0;
|
setDestinationDistance(routeStops[routeStops.length - 1].distanceKm || 0);
|
||||||
const allStops = routeStops.map((stop: any) => {
|
|
||||||
cumulative += stop.distanceKm || 0;
|
|
||||||
return { ...stop, _cumulative: cumulative };
|
|
||||||
});
|
|
||||||
setDestinationDistance(allStops[allStops.length - 1]._cumulative);
|
|
||||||
|
|
||||||
const middleStops = routeStops.slice(1, -1).map((stop: any, idx: number) => ({
|
const middleStops = routeStops.slice(1, -1).map((stop: any) => ({
|
||||||
stationId: stop.stationId,
|
stationId: stop.stationId,
|
||||||
sequence: stop.sequence,
|
sequence: stop.sequence,
|
||||||
distanceKm: stop.distanceKm,
|
distanceKm: stop.distanceKm,
|
||||||
distanceFromOrigin: allStops[idx + 1]._cumulative,
|
distanceFromOrigin: stop.distanceKm || 0,
|
||||||
}));
|
}));
|
||||||
setStops(middleStops);
|
setStops(middleStops);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -761,9 +761,12 @@ export default function TicketsPage() {
|
|||||||
<p className="text-emerald-100 text-xs font-semibold uppercase tracking-widest mb-1">Ticket Number</p>
|
<p className="text-emerald-100 text-xs font-semibold uppercase tracking-widest mb-1">Ticket Number</p>
|
||||||
<p className="text-white text-3xl font-mono font-bold tracking-wider">{t.ticketNumber || '—'}</p>
|
<p className="text-white text-3xl font-mono font-bold tracking-wider">{t.ticketNumber || '—'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right shrink-0">
|
<div className="text-right shrink-0 flex flex-col items-end gap-1">
|
||||||
<Badge variant="status" status={t.status || 'ACTIVE'}>{t.status || 'ACTIVE'}</Badge>
|
<Badge variant="status" status={t.status || 'ACTIVE'}>{t.status || 'ACTIVE'}</Badge>
|
||||||
{t.validatedAt && <p className="text-emerald-200 text-xs mt-1">Validated {formatDateTime(t.validatedAt)}</p>}
|
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${t.qrCode ? 'bg-emerald-200 text-emerald-900' : 'bg-white/20 text-white/60'}`}>
|
||||||
|
{t.qrCode ? '✓ QR Available' : 'No QR'}
|
||||||
|
</span>
|
||||||
|
{t.validatedAt && <p className="text-emerald-200 text-xs">Validated {formatDateTime(t.validatedAt)}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 grid grid-cols-3 gap-3">
|
<div className="mt-4 grid grid-cols-3 gap-3">
|
||||||
@@ -835,13 +838,29 @@ export default function TicketsPage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
{t.qrCode && (
|
||||||
|
<section>
|
||||||
|
<SectionHeader title="QR Code" />
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="bg-white p-4 rounded-xl border border-muted inline-block">
|
||||||
|
<img
|
||||||
|
src={t.qrCode.startsWith('data:') ? t.qrCode : `data:image/png;base64,${t.qrCode}`}
|
||||||
|
alt={`QR Code for ${t.ticketNumber}`}
|
||||||
|
className="w-48 h-48 object-contain"
|
||||||
|
/>
|
||||||
|
<p className="text-center text-xs text-muted-foreground mt-2 font-mono">{t.ticketNumber}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Validation */}
|
{/* Validation */}
|
||||||
<section>
|
<section>
|
||||||
<SectionHeader title="Validation & Timestamps" />
|
<SectionHeader title="Validation & Timestamps" />
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
<Field label="Validated At" value={t.validatedAt ? formatDateTime(t.validatedAt) : 'Not validated'} />
|
<Field label="Validated At" value={t.validatedAt ? formatDateTime(t.validatedAt) : 'Not validated'} />
|
||||||
<Field label="Boarded At" value={t.boardedAt ? formatDateTime(t.boardedAt) : 'Not boarded'} />
|
<Field label="Boarded At" value={t.boardedAt ? formatDateTime(t.boardedAt) : 'Not boarded'} />
|
||||||
<Field label="QR Code" value={t.qrCode ? 'Generated' : 'N/A'} />
|
|
||||||
<Field label="Created" value={formatDateTime(t.createdAt)} />
|
<Field label="Created" value={formatDateTime(t.createdAt)} />
|
||||||
<Field label="Last Updated" value={formatDateTime(t.updatedAt)} />
|
<Field label="Last Updated" value={formatDateTime(t.updatedAt)} />
|
||||||
<Field label="Ticket ID" value={t.id} mono truncate />
|
<Field label="Ticket ID" value={t.id} mono truncate />
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export default function DataTable<T extends Record<string, any>>({
|
|||||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{sortedData.map((item, index) => (
|
{sortedData.map((item, index) => (
|
||||||
<tr
|
<tr
|
||||||
key={item.id || index}
|
key={item.id ?? index}
|
||||||
onClick={() => onRowClick?.(item)}
|
onClick={() => onRowClick?.(item)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'transition-colors',
|
'transition-colors',
|
||||||
@@ -186,15 +186,16 @@ export default function DataTable<T extends Record<string, any>>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={(el) => { buttonRefs.current[item.id] = el; }}
|
ref={(el) => { buttonRefs.current[item.id ?? index] = el; }}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
setDropdownPosition({
|
setDropdownPosition({
|
||||||
top: rect.bottom + window.scrollY,
|
top: rect.bottom + window.scrollY,
|
||||||
left: rect.right + window.scrollX - 192, // 192px = w-48
|
left: rect.right + window.scrollX - 192,
|
||||||
});
|
});
|
||||||
setExpandedActions(expandedActions === item.id ? null : item.id);
|
const key = item.id ?? String(index);
|
||||||
|
setExpandedActions(expandedActions === key ? null : key);
|
||||||
}}
|
}}
|
||||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
>
|
>
|
||||||
@@ -229,9 +230,9 @@ export default function DataTable<T extends Record<string, any>>({
|
|||||||
>
|
>
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{actions
|
{actions
|
||||||
?.filter(action => !action.show || action.show(sortedData.find(item => item.id === expandedActions)!))
|
?.filter(action => !action.show || action.show(sortedData.find((item, i) => (item.id ?? String(i)) === expandedActions)!))
|
||||||
.map((action, actionIndex) => {
|
.map((action, actionIndex) => {
|
||||||
const item = sortedData.find(item => item.id === expandedActions);
|
const item = sortedData.find((item, i) => (item.id ?? String(i)) === expandedActions);
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user