From 9b6423392b22a813d1ceb91ecbdb93e7374f1140 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 6 Jul 2026 16:30:48 +0300 Subject: [PATCH] UAT issues resolution --- .../migration.sql | 3 + .../migration.sql | 5 + .../migration.sql | 2 + apps/edr-passenger-api/prisma/schema.prisma | 6 + apps/edr-passenger-api/src/app.module.ts | 11 - .../modules/currencies/currencies.service.ts | 4 - .../src/modules/packages/packages.dto.ts | 4 + .../src/modules/packages/packages.service.ts | 30 +- .../src/modules/schedules/schedules.dto.ts | 1 + .../modules/schedules/schedules.service.ts | 1 + .../src/modules/search/search.service.ts | 4 + .../backoffice/src/app/coaches/page.tsx | 92 +++--- .../backoffice/src/app/currencies/page.tsx | 115 +++++++- .../src/app/package-bookings/page.tsx | 36 ++- .../backoffice/src/app/packages/page.tsx | 67 +++-- .../backoffice/src/app/schedules/page.tsx | 29 +- .../portal/src/app/booking/review/page.tsx | 19 +- .../portal/src/app/packages/[id]/page.tsx | 271 +++++++++++------- 18 files changed, 467 insertions(+), 233 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260706081216_add_package_booking_adult_child_count/migration.sql create mode 100644 apps/edr-passenger-api/prisma/migrations/20260706083104_add_price_tier_seat_class_id/migration.sql create mode 100644 apps/edr-passenger-api/prisma/migrations/20260706131529_add_schedule_is_package_only/migration.sql diff --git a/apps/edr-passenger-api/prisma/migrations/20260706081216_add_package_booking_adult_child_count/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260706081216_add_package_booking_adult_child_count/migration.sql new file mode 100644 index 000000000..a43444c92 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260706081216_add_package_booking_adult_child_count/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "PackageBooking" ADD COLUMN "adultCount" INTEGER NOT NULL DEFAULT 1, +ADD COLUMN "childCount" INTEGER NOT NULL DEFAULT 0; diff --git a/apps/edr-passenger-api/prisma/migrations/20260706083104_add_price_tier_seat_class_id/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260706083104_add_price_tier_seat_class_id/migration.sql new file mode 100644 index 000000000..015402584 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260706083104_add_price_tier_seat_class_id/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "PackagePriceTier" ADD COLUMN "seatClassId" TEXT; + +-- AddForeignKey +ALTER TABLE "PackagePriceTier" ADD CONSTRAINT "PackagePriceTier_seatClassId_fkey" FOREIGN KEY ("seatClassId") REFERENCES "SeatClass"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/migrations/20260706131529_add_schedule_is_package_only/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260706131529_add_schedule_is_package_only/migration.sql new file mode 100644 index 000000000..050cfd093 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260706131529_add_schedule_is_package_only/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "TrainSchedule" ADD COLUMN "isPackageOnly" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 83c2b884e..0cd1756fd 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -100,6 +100,7 @@ model SeatClass { fareRules FareRule[] routeFareRules RouteFareRule[] segmentFares SegmentFareRule[] + packagePriceTiers PackagePriceTier[] @@unique([coachTypeId, name]) @@index([coachTypeId]) @@index([coachTypeId, nationalityType, bedPosition]) @@ -367,6 +368,7 @@ model TrainSchedule { onTimePercent Int @default(100) carbonRating String @default("A") notes String? + isPackageOnly Boolean @default(false) train Train @relation(fields: [trainId], references: [id]) route Route? @relation(fields: [routeId], references: [id]) originStation Station @relation("OriginTrips", fields: [originStationId], references: [id]) @@ -1448,6 +1450,7 @@ model TravelPackage { model PackagePriceTier { id String @id @default(uuid()) packageId String + seatClassId String? seatType String label String priceMinor Int @@ -1456,6 +1459,7 @@ model PackagePriceTier { bookedSeats Int @default(0) package TravelPackage @relation(fields: [packageId], references: [id]) + seatClass SeatClass? @relation(fields: [seatClassId], references: [id]) bookings Booking[] packageBookings PackageBooking[] inquiries PackageInquiry[] @@ -1474,6 +1478,8 @@ model PackageBooking { contactPhone String? status BookingStatus @default(PENDING_PAYMENT) passengerCount Int @default(1) + adultCount Int @default(1) + childCount Int @default(0) totalMinor Int currency String @default("ETB") displayCurrency Currency? diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index 834c69736..1e41076e7 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -8,7 +8,6 @@ import { EventEmitterModule } from '@nestjs/event-emitter'; import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'; import { IamModule as TriaIamModule } from '@tria-plc/iamapi-common/iam.module'; import { DataSeeder } from '@tria-plc/iamapi-common/db/seed/seeder'; -import { EOtpType } from '@tria-plc/iamapi-common'; import { SharedAuthModule } from '@tria-plc/api-common/modules/auth/shared-auth.module'; import { EDR_PASSENGER_APPLICATION, @@ -98,16 +97,6 @@ import { SegmentFareSeeder } from './seed/segment-fare.seeder'; TriaIamModule.forRoot({ applications: [EDR_PASSENGER_APPLICATION], permissions: EDR_PASSENGER_PERMISSIONS, - otpMessages: { - [EOtpType.MFA_LOGIN]: ({ otp }) => - `Your EDR Passenger login code is ${otp}. It will expire in 5 minutes.`, - [EOtpType.VERIFY_PHONE_NUMBER]: ({ otp }) => - `Your EDR Passenger phone verification code is ${otp}. It will expire in 5 minutes.`, - [EOtpType.RESET_PASSWORD]: ({ route }) => - `Reset your EDR Passenger password using this link: ${route}`, - [EOtpType.SET_PASSWORD]: ({ route }) => - `Set your EDR Passenger password using this link: ${route}`, - }, }), SharedAuthModule, PrismaModule, diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts index 2ccc79b70..b2708af5a 100644 --- a/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.service.ts @@ -32,10 +32,6 @@ export class CurrenciesService { async createCurrency(dto: CreateCurrencyDto) { const { code, name, symbol, baseCurrencyCode = 'ETB', exchangeRate } = dto; - if (!['ETB', 'USD', 'DJF'].includes(code.toUpperCase())) { - throw new BadRequestException('Unsupported currency code'); - } - if (exchangeRate <= 0) { throw new BadRequestException('Exchange rate must be positive'); } diff --git a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts index 0b7a25bba..d7257f179 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts @@ -3,6 +3,9 @@ import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class CreatePriceTierDto { + @ApiPropertyOptional({ description: 'SeatClass ID to link this tier to a specific seat class' }) + @IsOptional() @IsUUID() seatClassId?: string; + @ApiProperty({ example: 'HSC' }) @IsString() seatType: string; @@ -31,6 +34,7 @@ export class UpdateInquiryStatusDto { } export class UpdatePriceTierDto { + @ApiPropertyOptional() @IsOptional() @IsUUID() seatClassId?: string; @ApiPropertyOptional() @IsOptional() @IsString() seatType?: string; @ApiPropertyOptional() @IsOptional() @IsString() label?: string; @ApiPropertyOptional() @IsOptional() @IsInt() @Min(0) priceMinor?: number; diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index 294273d30..da97dcfa9 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -8,7 +8,7 @@ import { GuestBookingService } from '../bookings/guest-booking.service'; /** Package-specific fare rules */ const PKG_MAX_ADULTS = 5; -const PKG_MAX_CHILDREN = 2; +const PKG_CHILDREN_PER_ADULT = 2; // 2 children allowed per adult const PKG_CHILD_FARE_RATIO = 0.1; function calculatePackageFareBreakdown( @@ -68,7 +68,8 @@ export class PackagesService { if (adultCount < 1) throw new BadRequestException('At least one adult passenger required'); if (adultCount > PKG_MAX_ADULTS) throw new BadRequestException(`Maximum ${PKG_MAX_ADULTS} adults allowed per package booking`); - if (childCount > PKG_MAX_CHILDREN) throw new BadRequestException(`Maximum ${PKG_MAX_CHILDREN} children allowed per package booking`); + const maxChildren = adultCount * PKG_CHILDREN_PER_ADULT; + if (childCount > maxChildren) throw new BadRequestException(`Maximum ${PKG_CHILDREN_PER_ADULT} children per adult (${maxChildren} for ${adultCount} adult${adultCount !== 1 ? 's' : ''}) allowed per package booking`); const passengerCount = adultCount + childCount; const remaining = tier.availableSeats - tier.bookedSeats; @@ -81,14 +82,17 @@ export class PackagesService { ); // Resolve the seatClassId and coachTypeId that matches this tier's seatType from the outbound schedule coaches - let seatClassId: string | null = null; + let seatClassId: string | null = tier.seatClassId ?? null; + let seatClassName: string | null = null; let coachTypeId: string | null = null; for (const a of pkg.outboundSchedule.coachAssignments) { - const sc = a.coach.coachType?.seatClasses?.find( - (s: any) => s.name.toLowerCase().includes(tier.seatType.toLowerCase()) || - tier.seatType.toLowerCase().includes(s.name.toLowerCase()), - ); - if (sc) { seatClassId = sc.id; coachTypeId = a.coach.coachTypeId ?? a.coach.coachType?.id ?? null; break; } + const sc = seatClassId + ? a.coach.coachType?.seatClasses?.find((s: any) => s.id === seatClassId) + : a.coach.coachType?.seatClasses?.find( + (s: any) => s.name.toLowerCase().includes(tier.seatType.toLowerCase()) || + tier.seatType.toLowerCase().includes(s.name.toLowerCase()), + ); + if (sc) { seatClassId = sc.id; seatClassName = sc.name; coachTypeId = a.coach.coachTypeId ?? a.coach.coachType?.id ?? null; break; } } if (!coachTypeId && pkg.outboundSchedule.coachAssignments.length > 0) { const first = pkg.outboundSchedule.coachAssignments[0]; @@ -102,6 +106,7 @@ export class PackagesService { tierLabel: tier.label, seatType: tier.seatType, seatClassId, + seatClassName, coachTypeId, adultCount, childCount, @@ -111,7 +116,7 @@ export class PackagesService { pricePerChildMinor: childFareMinor, childFareNote: `Children pay ${PKG_CHILD_FARE_RATIO * 100}% of adult fare`, maxAdults: PKG_MAX_ADULTS, - maxChildren: PKG_MAX_CHILDREN, + maxChildren: adultCount * PKG_CHILDREN_PER_ADULT, totalMinor, currency: tier.currency, remainingSeats: remaining, @@ -203,7 +208,7 @@ export class PackagesService { const pkg = await this.prisma.travelPackage.findUnique({ where: { id }, include: { - priceTiers: true, + priceTiers: { include: { seatClass: { include: { coachType: true } } } }, outboundSchedule: { include: { originStation: true, @@ -369,7 +374,8 @@ export class PackagesService { if (adultCount < 1) throw new BadRequestException('At least one adult passenger required'); if (adultCount > PKG_MAX_ADULTS) throw new BadRequestException(`Maximum ${PKG_MAX_ADULTS} adults allowed per package booking`); - if (childCount > PKG_MAX_CHILDREN) throw new BadRequestException(`Maximum ${PKG_MAX_CHILDREN} children allowed per package booking`); + const maxChildrenBook = adultCount * PKG_CHILDREN_PER_ADULT; + if (childCount > maxChildrenBook) throw new BadRequestException(`Maximum ${PKG_CHILDREN_PER_ADULT} children per adult (${maxChildrenBook} for ${adultCount} adult${adultCount !== 1 ? 's' : ''}) allowed per package booking`); const passengerCount = adultCount + childCount; const remaining = tier.availableSeats - tier.bookedSeats; @@ -398,6 +404,8 @@ export class PackagesService { contactPhone: dto.contactPhone, promoCode: dto.promoCode, passengerCount, + adultCount, + childCount, totalMinor, currency: 'ETB', displayCurrency, diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 80f3ad13b..3844e57cf 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -59,6 +59,7 @@ export class UpdateScheduleDto { @ApiPropertyOptional({ example: '2026-06-15T20:00:00Z', description: 'Scheduled arrival at the last stop (destination)' }) @IsOptional() @IsDateString() arrivalAt?: string; @ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus; @ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>; + @ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean; } export class UpdateStopTimeDto { diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 19aa5ed0e..bd7cc7258 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -632,6 +632,7 @@ export class SchedulesService { } if (dto.status) updateData.status = dto.status; + if (dto.isPackageOnly !== undefined) updateData.isPackageOnly = dto.isPackageOnly; if (Object.keys(updateData).length > 0) { await this.prisma.trainSchedule.update({ where: { id }, data: updateData }); diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 621e5487d..2942625ca 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -157,6 +157,7 @@ export class SearchService { const schedules = await this.prisma.trainSchedule.findMany({ where: { status: { in: ['SCHEDULED', 'BOARDING'] }, + isPackageOnly: false, OR: [ { departureAt: { gte: windowStart, lt: requestedDate } }, { departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } }, @@ -192,6 +193,7 @@ export class SearchService { const schedules = await this.prisma.trainSchedule.findMany({ where: { status: { in: ['SCHEDULED', 'BOARDING'] }, + isPackageOnly: false, departureAt: { gte: date < now ? now : date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } }, }, @@ -230,6 +232,7 @@ export class SearchService { this.prisma.trainSchedule.findMany({ where: { status: { in: ['SCHEDULED', 'BOARDING'] }, + isPackageOnly: false, departureAt: { gte: dayStart, lt: dayEnd }, stopTimes: { some: { stationId: originStationId } }, }, @@ -238,6 +241,7 @@ export class SearchService { this.prisma.trainSchedule.findMany({ where: { status: { in: ['SCHEDULED', 'BOARDING'] }, + isPackageOnly: false, departureAt: { gte: dayStart, lt: leg2WindowEnd }, }, include: SCHEDULE_INCLUDE, diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index 91eec0082..f97456a3d 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -147,6 +147,7 @@ export default function CoachesPage() { const [editingItem, setEditingItem] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null }); const [selectedCoachTypeId, setSelectedCoachTypeId] = useState(''); + const [isBedCoach, setIsBedCoach] = useState(false); const [exportUtilModalOpen, setExportUtilModalOpen] = useState(false); const [exportUtilFormat, setExportUtilFormat] = useState<'csv' | 'excel' | 'pdf'>('csv'); @@ -454,6 +455,7 @@ export default function CoachesPage() { onClick: (item: any) => { setEditingItem({ ...item, isCoach: true }); setSelectedCoachTypeId(item.coachTypeId || ''); + setIsBedCoach(!!(item.bedCategory || item.coachType?.name?.toLowerCase().includes('bed'))); setShowModal(true); }, variant: 'secondary' as const, @@ -479,6 +481,7 @@ export default function CoachesPage() { onClick={() => { setEditingItem(null); setSelectedCoachTypeId(''); + setIsBedCoach(false); setSearch(''); setShowModal(true); }} @@ -700,6 +703,7 @@ export default function CoachesPage() { setShowModal(false); setEditingItem(null); setSelectedCoachTypeId(''); + setIsBedCoach(false); }} title={ activeTab === 'types' @@ -779,8 +783,14 @@ export default function CoachesPage() { - - - - -

- Select if this is a bed coach -

- - -
- - -

- Only applies to bed coaches -

-
- - ) : null; - })()} + {isBedCoach && ( + <> +
+ + +
+
+ + +
+ + )}
@@ -903,6 +894,7 @@ export default function CoachesPage() { setShowModal(false); setEditingItem(null); setSelectedCoachTypeId(''); + setIsBedCoach(false); }} > Cancel diff --git a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx index 8de163da9..4cf393713 100644 --- a/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/currencies/page.tsx @@ -2,10 +2,11 @@ import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Edit, Loader2, RefreshCw } from 'lucide-react'; +import { Edit, Loader2, Plus, RefreshCw, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { apiClient } from '@/lib/api-client'; interface CurrencyRate { @@ -29,6 +30,9 @@ export default function CurrenciesPage() { const [editingRate, setEditingRate] = useState(null); const [rateInput, setRateInput] = useState(''); const [error, setError] = useState(null); + const [showAddModal, setShowAddModal] = useState(false); + const [addForm, setAddForm] = useState({ code: '', name: '', symbol: '', exchangeRate: '' }); + const [deleteConfirm, setDeleteConfirm] = useState(null); const queryClient = useQueryClient(); const { data: currencies = [], isLoading } = useQuery({ @@ -49,6 +53,26 @@ export default function CurrenciesPage() { }, }); + const createMutation = useMutation({ + mutationFn: (data: any) => apiClient.post('/currencies', data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['currencies'] }); + setShowAddModal(false); + setAddForm({ code: '', name: '', symbol: '', exchangeRate: '' }); + setError(null); + }, + onError: (err: any) => setError(err.response?.data?.message || 'Failed to add currency'), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => apiClient.delete(`/currencies/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['currencies'] }); + setDeleteConfirm(null); + }, + onError: (err: any) => setError(err.response?.data?.message || 'Failed to delete currency'), + }); + const syncMutation = useMutation({ mutationFn: () => apiClient.post('/currencies/sync-rates', {}), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['currencies'] }), @@ -121,12 +145,8 @@ export default function CurrenciesPage() { ]; const actions = [ - { - label: 'Edit Rate', - onClick: handleEdit, - variant: 'secondary' as const, - icon: Edit, - }, + { label: 'Edit', onClick: handleEdit, variant: 'secondary' as const, icon: Edit }, + { label: 'Delete', onClick: (c: CurrencyRate) => setDeleteConfirm(c), variant: 'danger' as const, icon: Trash2 }, ]; return ( @@ -138,14 +158,17 @@ export default function CurrenciesPage() { Manage ETB exchange rates for display currencies (DJF, USD)

- syncMutation.mutate()} - loading={syncMutation.isPending} - > - Sync Rates - +
+ { setError(null); setShowAddModal(true); }}>Add Currency + syncMutation.mutate()} + loading={syncMutation.isPending} + > + Sync Rates + +
{error && !editingRate && ( @@ -204,6 +227,68 @@ export default function CurrenciesPage() {

• Rates apply globally; changes take effect immediately on the next booking or fare quote

+ { setShowAddModal(false); setError(null); }} + title="Add Currency" + size="sm" + > +
+ {error && ( +
{error}
+ )} +
+
+ + setAddForm({ ...addForm, code: e.target.value.toUpperCase() })} /> +
+
+ + setAddForm({ ...addForm, symbol: e.target.value })} /> +
+
+
+ + setAddForm({ ...addForm, name: e.target.value })} /> +
+
+ + setAddForm({ ...addForm, exchangeRate: e.target.value })} /> +
+
+ { setShowAddModal(false); setError(null); }}>Cancel + { + if (!addForm.code || !addForm.name || !addForm.symbol || !addForm.exchangeRate) { + setError('All fields are required'); return; + } + const rate = parseFloat(addForm.exchangeRate); + if (isNaN(rate) || rate <= 0) { setError('Exchange rate must be a positive number'); return; } + createMutation.mutate({ code: addForm.code, name: addForm.name, symbol: addForm.symbol, exchangeRate: rate }); + }} + > + Add Currency + +
+
+
+ + setDeleteConfirm(null)} + onConfirm={() => deleteMutation.mutate(deleteConfirm!.id)} + title="Delete Currency" + message={`Delete ${deleteConfirm?.code} (${CURRENCY_META[deleteConfirm?.code ?? '']?.name ?? deleteConfirm?.code})? This will remove the exchange rate record.`} + confirmText="Delete" + isDanger + isLoading={deleteMutation.isPending} + /> + { setEditingRate(null); setError(null); }} diff --git a/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx index 2912eeee3..88301428d 100644 --- a/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/package-bookings/page.tsx @@ -214,21 +214,31 @@ export default function PackageBookingsPage() {
- {b.passengers.map((p: any, i: number) => ( -
-
- {i + 1} -
-

{p.passengerName}

-

- {p.dateOfBirth ? new Date(p.dateOfBirth).toLocaleDateString() : ''} - {p.idDocumentType ? ` · ${p.idDocumentType}` : ''} - {p.passportNumber ? ` · ${p.passportNumber}` : ''} -

+ {b.passengers.map((p: any, i: number) => { + const isChild = i >= (b.adultCount ?? b.passengerCount); + return ( +
+
+ {i + 1} +
+

{p.passengerName}

+

+ {p.dateOfBirth ? new Date(p.dateOfBirth).toLocaleDateString() : ''} + {p.idDocumentType ? ` · ${p.idDocumentType}` : ''} + {p.passportNumber ? ` · ${p.passportNumber}` : ''} +

+
+ + {isChild ? 'CHILD' : 'ADULT'} +
-
- ))} + ); + })}
)} diff --git a/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx b/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx index 601cb17c6..ab0386a19 100644 --- a/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx @@ -8,7 +8,7 @@ import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import Modal from '@/components/ui/Modal'; -import { packagesApi, stationsApi, schedulesApi } from '@/lib/api'; +import { packagesApi, stationsApi, schedulesApi, seatClassesApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; const toLocal = (iso?: string) => { @@ -42,7 +42,7 @@ export default function PackagesPage() { const [deactivateConfirm, setDeactivateConfirm] = useState(null); const [tiersPackage, setTiersPackage] = useState(null); const [editingTier, setEditingTier] = useState(null); - const [tierForm, setTierForm] = useState({ seatType: '', label: '', priceMinor: '', availableSeats: '' }); + const [tierForm, setTierForm] = useState({ seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' }); const [deleteTierConfirm, setDeleteTierConfirm] = useState(null); const [tierError, setTierError] = useState(null); const [deletePackageConfirm, setDeletePackageConfirm] = useState(null); @@ -64,8 +64,14 @@ export default function PackagesPage() { queryFn: () => schedulesApi.getAll(), }); + const { data: seatClassesData } = useQuery({ + queryKey: ['seat-classes-all'], + queryFn: () => seatClassesApi.getAll(), + }); + const stations: any[] = stationsData?.items || stationsData?.data || (Array.isArray(stationsData) ? stationsData : []); const schedules: any[] = schedulesData?.items || schedulesData?.data || (Array.isArray(schedulesData) ? schedulesData : []); + const seatClasses: any[] = Array.isArray(seatClassesData) ? seatClassesData : (seatClassesData as any)?.items || (seatClassesData as any)?.data || []; const createMutation = useMutation({ mutationFn: packagesApi.create, @@ -87,7 +93,7 @@ export default function PackagesPage() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['packages'] }); setDeactivateConfirm(null); }, }); - const emptyTierForm = { seatType: '', label: '', priceMinor: '', availableSeats: '' }; + const emptyTierForm = { seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' }; const addTierMutation = useMutation({ mutationFn: ({ packageId, data }: { packageId: string; data: any }) => packagesApi.addTier(packageId, data), @@ -135,13 +141,19 @@ export default function PackagesPage() { const openEditTier = (tier: any) => { setEditingTier(tier); - setTierForm({ seatType: tier.seatType, label: tier.label, priceMinor: String(tier.priceMinor), availableSeats: String(tier.availableSeats) }); + setTierForm({ seatClassId: tier.seatClassId ?? '', seatType: tier.seatType, label: tier.label, priceMinor: String(tier.priceMinor), availableSeats: String(tier.availableSeats) }); setTierError(null); }; const handleTierSubmit = async (e: React.FormEvent) => { e.preventDefault(); - const payload = { seatType: tierForm.seatType, label: tierForm.label, priceMinor: parseInt(tierForm.priceMinor), availableSeats: parseInt(tierForm.availableSeats) }; + const payload: any = { + seatType: tierForm.seatType, + label: tierForm.label, + priceMinor: parseInt(tierForm.priceMinor), + availableSeats: parseInt(tierForm.availableSeats), + ...(tierForm.seatClassId ? { seatClassId: tierForm.seatClassId } : {}), + }; if (editingTier) { await updateTierMutation.mutateAsync({ tierId: editingTier.id, data: payload }); } else { @@ -267,7 +279,7 @@ export default function PackagesPage() { { label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit }, { label: 'Tiers', icon: Layers, variant: 'secondary' as const, - onClick: (p: any) => { setTiersPackage(p); setEditingTier(null); setTierForm({ seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); }, + onClick: (p: any) => { setTiersPackage(p); setEditingTier(null); setTierForm({ seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); }, }, { label: 'Activate', icon: CheckCircle, variant: 'primary' as const, @@ -432,8 +444,12 @@ export default function PackagesPage() { {(tiersPackage.priceTiers ?? []).map((t: any) => (
- {t.label} - ({t.seatType}) + {t.seatType} + {t.seatClassId && ( + + {seatClasses.find((sc: any) => sc.id === t.seatClassId)?.coachType.type ?? 'Linked'} + + )}
{formatCurrency(t.priceMinor, 'ETB')} · {t.bookedSeats}/{t.availableSeats} booked
@@ -454,16 +470,31 @@ export default function PackagesPage() {

{editingTier ? 'Edit Tier' : 'Add New Tier'}

-
- - setTierForm((f) => ({ ...f, seatType: e.target.value }))} /> -
-
- - setTierForm((f) => ({ ...f, label: e.target.value }))} /> +
+ +
+
{editingTier && ( - { setEditingTier(null); setTierForm({ seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); }}>Cancel + { setEditingTier(null); setTierForm({ seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); }}>Cancel )} {editingTier ? 'Update Tier' : 'Add Tier'} diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index bb25577f3..801dc43d1 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -22,6 +22,7 @@ interface Schedule { originStation?: { id: string; name: string }; destinationStation?: { id: string; name: string }; coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>; + isPackageOnly?: boolean; } interface Train { @@ -105,6 +106,7 @@ export default function SchedulesPage() { arrivalAt: '', status: 'SCHEDULED', coachIds: [] as string[], + isPackageOnly: false, }); const [filters, setFilters] = useState({ @@ -279,6 +281,7 @@ export default function SchedulesPage() { departureAt: depLocal.toISOString(), arrivalAt: arrLocal.toISOString(), status: editForm.status, + isPackageOnly: editForm.isPackageOnly, coaches: editForm.coachIds.map((coachId: string, idx: number) => ({ coachId, positionNumber: idx + 1, @@ -336,6 +339,7 @@ export default function SchedulesPage() { arrivalAt: arrStr, status: schedule.status, coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [], + isPackageOnly: schedule.isPackageOnly ?? false, }); setError(null); setShowEditModal(true); @@ -455,9 +459,14 @@ export default function SchedulesPage() { key: 'status', label: 'Status', render: (schedule: Schedule) => ( - - {schedule.status} - +
+ + {schedule.status} + + {schedule.isPackageOnly && ( + PKG + )} +
), }, ] as any; @@ -1040,6 +1049,20 @@ export default function SchedulesPage() {
+
+ setEditForm({ ...editForm, isPackageOnly: e.target.checked })} + className="w-4 h-4 rounded" + /> + +
+
diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 359da2236..6e057a3e8 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -277,9 +277,10 @@ export default function ReviewPage() { displayCurrency: displayCurrency, passengers: passengers.map((p) => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; + const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId; return { - seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''), - ...(isRoundTrip && { returnSeatId: (p as any).inboundSeatId || '' }), + ...(seatId ? { seatId } : {}), + ...(isRoundTrip && (p as any).inboundSeatId ? { returnSeatId: (p as any).inboundSeatId } : {}), passengerName: p.name, dateOfBirth: p.dateOfBirth, idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', @@ -320,9 +321,10 @@ export default function ReviewPage() { displayCurrency: displayCurrency, passengers: passengers.map(p => { const isEthiopian = p.nationality === 'ETHIOPIAN' || p.nationality === 'Ethiopian'; + const seatId = isRoundTrip ? (p as any).outboundSeatId : p.seatId; return { - seatId: isRoundTrip ? (p as any).outboundSeatId : (p.seatId || ''), - ...(isRoundTrip && { returnSeatId: (p as any).inboundSeatId || '' }), + ...(seatId ? { seatId } : {}), + ...(isRoundTrip && (p as any).inboundSeatId ? { returnSeatId: (p as any).inboundSeatId } : {}), passengerName: p.name, dateOfBirth: p.dateOfBirth, idDocumentType: isEthiopian ? 'NATIONAL_ID' : 'PASSPORT', @@ -454,6 +456,11 @@ export default function ReviewPage() { const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p => !isChild(p)).length; const childPassengerCount = searchCriteria?.childCount ?? passengers.filter(p => isChild(p)).length; + // For package bookings, passengers are initialized without dateOfBirth so isChild() is + // unreliable. Use the stored adultCount from searchCriteria to determine category by index. + const isPackageChild = (index: number) => + isPackageBooking ? index >= adultPassengerCount : isChild(passengers[index]); + // Per-seat fare captured on the seats page (bed-position-aware, computed locally from // the schedule's own coachTypes/classes) is guaranteed correct for berths, unlike the // backend /search/fare-breakdown call whose seatClassId matching for bed positions can't @@ -485,8 +492,8 @@ export default function ReviewPage() { {passengers.map((p, i) => { const line = fareBreakdown?.passengers?.[i]; - const isChildPassenger = isChild(p); - const isFreeChild = !isPackageBooking && (line?.isFree ?? (isChildPassenger && isFirstChild(passengers, i))); + const isChildPassenger = isPackageChild(i); + const isFreeChild = !isPackageBooking && (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i))); const seatFare = getPassengerSeatFare(p); const passengerTotal = isPackageBooking ? (isChildPassenger ? pkgChildFare : pkgAdultFare) diff --git a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx index a38515e03..457425578 100644 --- a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx @@ -21,6 +21,9 @@ import { Tag, Shield, X, + Star, + Bed, + Armchair, } from "lucide-react"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -58,6 +61,13 @@ interface Schedule { routeStops?: RouteStop[]; } +interface CoachTypeInfo { + id: string; + name: string; + code: string; + type: string; // 'passenger' | 'sleeper' | 'dining' | 'baggage' +} + interface PriceTier { id: string; packageId: string; @@ -67,6 +77,7 @@ interface PriceTier { currency: string; availableSeats: number; bookedSeats: number; + seatClass?: { coachType?: CoachTypeInfo }; } interface PackageDetail { @@ -192,101 +203,157 @@ function JourneyCard({ schedule, label }: { schedule: Schedule; label: string }) ); } -// ─── Price Tiers Panel ──────────────────────────────────────────────────────── +// ─── Coach Type Group Panel (Step 1 + Step 2 inline) ───────────────────────── + +function groupTiersByCoachType(tiers: PriceTier[]): Array<{ + coachTypeId: string; + coachTypeName: string; + coachTypeCode: string; + coachTypeType: string; + tiers: PriceTier[]; + minPrice: number; + currency: string; +}> { + const map = new Map(); + for (const tier of tiers) { + const ct = tier.seatClass?.coachType; + const key = ct?.id ?? `__ungrouped__${tier.seatType}`; + if (!map.has(key)) { + map.set(key, { + coachTypeId: ct?.id ?? key, + coachTypeName: ct?.name ?? tier.seatType, + coachTypeCode: ct?.code ?? '', + coachTypeType: ct?.type ?? 'passenger', + tiers: [], + }); + } + map.get(key)!.tiers.push(tier); + } + return Array.from(map.values()).map((g) => ({ + ...g, + minPrice: Math.min(...g.tiers.map((t) => t.priceMinor)), + currency: g.tiers[0]?.currency ?? 'ETB', + })); +} + +function getCoachIcon(coachTypeType: string) { + const lower = coachTypeType.toLowerCase(); + if (lower.includes('sleeper')) return Star; + if (lower.includes('bed') || lower.includes('sleep')) return Bed; + return Armchair; +} + +// Capitalise first letter of each word, replace underscores with spaces +function formatCoachTypeLabel(type: string): string { + return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} function PriceTiersPanel({ tiers, - selectedTierId, - onSelect, onBookNow, isRoundTrip, }: { tiers: PriceTier[]; - selectedTierId: string | null; - onSelect: (id: string) => void; - onBookNow: () => void; + onBookNow: (coachTypeId: string) => void; isRoundTrip: boolean; }) { + const [selectedId, setSelectedId] = useState(null); const priceMultiplier = isRoundTrip ? 2 : 1; + const groups = groupTiersByCoachType(tiers ?? []); + + if (!tiers?.length) { + return ( +
+

No price tiers available

+
+ ); + } + return ( -
-

- Select Seat Type -

- - {!tiers?.length ? ( -

- No price tiers available -

- ) : ( -
- {tiers.map((tier) => { - const soldOut = tier.availableSeats === 0; - const selected = tier.id === selectedTierId; - return ( -
!soldOut && onSelect(tier.id)} - className={`rounded-xl border-2 p-3.5 transition-all ${ - soldOut - ? "border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed" - : selected - ? "border-primary bg-primary/5" - : "border-gray-200 dark:border-gray-700 hover:border-primary/50 hover:shadow-sm cursor-pointer" - }`} - > - {/* Row 1: radio + full label */} -
-
- {selected &&
} -
-

- {tier.label.trim()} -

-
- - {/* Row 2: seatType badge + seats + price */} -
-
- - {tier.seatType.trim()} - - {soldOut ? ( - - SOLD OUT - - ) : ( - - {tier.availableSeats} left - - )} -
-

- {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} -

-
- - {/* Book Now — shown only when selected */} - {selected && ( - - )} +
+

Select Coach Type

+ {groups.map((group) => { + const CoachIcon = getCoachIcon(group.coachTypeType); + const allSoldOut = group.tiers.every((t) => t.availableSeats === 0); + const isSelected = selectedId === group.coachTypeId; + return ( +
!allSoldOut && setSelectedId(isSelected ? null : group.coachTypeId)} + > + {/* Coach type header */} +
+
+
- ); - })} -
- )} +
+

+ {formatCoachTypeLabel(group.coachTypeType)} +

+

+ From {formatPrice(group.minPrice * priceMultiplier, group.currency)} + {allSoldOut && · Sold out} +

+
+ {!allSoldOut && ( +
+ {isSelected && } +
+ )} +
+ + {/* All available classes for this coach type */} +
+ {group.tiers.map((tier) => { + const soldOut = tier.availableSeats === 0; + return ( +
+
+
+

{tier.seatType.trim()}

+

+ {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} + {soldOut ? ( + Sold out + ) : ( + {tier.availableSeats} left + )} +

+
+
+ ); + })} +
+ + {/* Book Now — only when this group is selected */} + {isSelected && !allSoldOut && ( +
+ +
+ )} +
+ ); + })}
); } @@ -294,7 +361,7 @@ function PriceTiersPanel({ // ─── Passenger count picker ────────────────────────────────────────────────── const PKG_MAX_ADULTS = 5; -const PKG_MAX_CHILDREN = 2; +const PKG_CHILDREN_PER_ADULT = 2; const PKG_CHILD_FARE_RATIO = 0.1; function PassengerCountModal({ @@ -335,15 +402,15 @@ function PassengerCountModal({
-

Selected tier

-

{tier.label.trim()}

-

{remaining} seats remaining · {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult

+

Coach type

+

{tier.seatClass?.coachType?.type ? formatCoachTypeLabel(tier.seatClass.coachType.type) : tier.label.trim()}

+

{remaining} seats remaining · prices from {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult · actual class chosen on seat map

{[ { label: "Adults", sub: `Age 5+ · max ${PKG_MAX_ADULTS}`, value: adultCount, min: 1, max: Math.min(PKG_MAX_ADULTS, remaining), set: setAdultCount }, - { label: "Children", sub: `Under 5 · max ${PKG_MAX_CHILDREN} · 10% of adult fare`, value: childCount, min: 0, max: Math.min(PKG_MAX_CHILDREN, remaining - adultCount), set: setChildCount }, + { label: "Children", sub: `Under 5 · max ${PKG_CHILDREN_PER_ADULT} per adult · 10% of adult fare`, value: childCount, min: 0, max: Math.min(adultCount * PKG_CHILDREN_PER_ADULT, remaining - adultCount), set: setChildCount }, ].map(({ label, sub, value, min, max, set }) => (
@@ -426,7 +493,7 @@ export default function PackageDetailPage() { const id = params?.id as string; const { clearBooking, setSearchCriteria, setSelectedSchedule, setOutboundSchedule, setInboundSchedule, setPassengers, setPackageContext } = useBookingStore(); - const [selectedTierId, setSelectedTierId] = useState(null); + const [selectedCoachTypeId, setSelectedCoachTypeId] = useState(null); const [passengerModalOpen, setPassengerModalOpen] = useState(false); const [bookingContextLoading, setBookingContextLoading] = useState(false); const [bookingContextError, setBookingContextError] = useState(null); @@ -443,17 +510,21 @@ export default function PackageDetailPage() { ? pkg.outboundSchedule.routeStops.map((rs) => rs.station).filter(Boolean) : []; - const selectedTier = pkg?.priceTiers?.find((t) => t.id === selectedTierId); + // For the passenger modal, use the cheapest available tier in the selected coach type group + const groups = pkg ? groupTiersByCoachType(pkg.priceTiers) : []; + const selectedGroup = groups.find((g) => g.coachTypeId === selectedCoachTypeId); + // Representative tier for the modal header (cheapest available) + const representativeTier = selectedGroup?.tiers.find((t) => t.availableSeats > 0) ?? selectedGroup?.tiers[0] ?? null; const isRoundTripPkg = pkg?.journeyType === 'ROUND_TRIP'; const handleBookNow = async (adultCount: number, childCount: number, departureStationId: string, departureStationName: string) => { - if (!selectedTier || !pkg) return; + if (!representativeTier || !pkg) return; setBookingContextLoading(true); setBookingContextError(null); try { const ctx: any = await apiClient.get( - `/packages/${id}/booking-context?tierId=${selectedTier.id}&adultCount=${adultCount}&childCount=${childCount}`, + `/packages/${id}/booking-context?tierId=${representativeTier.id}&adultCount=${adultCount}&childCount=${childCount}`, ); clearBooking(); @@ -473,7 +544,7 @@ export default function PackageDetailPage() { duration: s.durationMinutes ? `${Math.floor(s.durationMinutes / 60)}h ${s.durationMinutes % 60}m` : "", baseFareAdult: Math.round(ctx.totalMinor / passengerCount), baseFareChild: 0, - displayCurrency: selectedTier.currency, + displayCurrency: representativeTier.currency, selectedSeatClass: ctx.seatClassId, selectedSeatClassName: ctx.seatClassName ?? "", seatClassName: ctx.seatClassName ?? "", @@ -510,7 +581,7 @@ export default function PackageDetailPage() { ); // Store per-adult tier price (×1 leg); review page applies round-trip multiplier and child pricing - setPackageContext(id, selectedTier.id, selectedTier.priceMinor, pkg.name, departureStationId, departureStationName); + setPackageContext(id, representativeTier.id, representativeTier.priceMinor, pkg.name, departureStationId, departureStationName); router.push("/booking/passengers"); } catch (err: any) { @@ -557,9 +628,9 @@ export default function PackageDetailPage() { return (
{/* Passenger count modal */} - {passengerModalOpen && selectedTier && ( + {passengerModalOpen && representativeTier && ( { setPassengerModalOpen(false); setBookingContextError(null); }} onConfirm={handleBookNow} loading={bookingContextLoading} @@ -751,9 +822,7 @@ export default function PackageDetailPage() {
setPassengerModalOpen(true)} + onBookNow={(coachTypeId) => { setSelectedCoachTypeId(coachTypeId); setPassengerModalOpen(true); }} isRoundTrip={isRoundTripPkg} />
@@ -764,9 +833,7 @@ export default function PackageDetailPage() {
setPassengerModalOpen(true)} + onBookNow={(coachTypeId) => { setSelectedCoachTypeId(coachTypeId); setPassengerModalOpen(true); }} isRoundTrip={isRoundTripPkg} />