mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
UAT issues resolution
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "PackageBooking" ADD COLUMN "adultCount" INTEGER NOT NULL DEFAULT 1,
|
||||
ADD COLUMN "childCount" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "TrainSchedule" ADD COLUMN "isPackageOnly" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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?
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -147,6 +147,7 @@ export default function CoachesPage() {
|
||||
const [editingItem, setEditingItem] = useState<any>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
|
||||
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
|
||||
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
|
||||
name="coachTypeId"
|
||||
className="input"
|
||||
defaultValue={editingItem?.coachTypeId || ''}
|
||||
onChange={(e) => setSelectedCoachTypeId(e.target.value)}
|
||||
value={selectedCoachTypeId}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value;
|
||||
setSelectedCoachTypeId(id);
|
||||
const ct = coachTypesArray.find((c: any) => c.id === id);
|
||||
const name = ct?.name?.toLowerCase() ?? '';
|
||||
setIsBedCoach(name.includes('bed') || name.includes('sleeper'));
|
||||
}}
|
||||
required
|
||||
>
|
||||
<option value="">Select Coach Type</option>
|
||||
@@ -804,54 +814,35 @@ export default function CoachesPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const selectedCoachType = coachTypesArray.find((ct: any) => ct.id === (selectedCoachTypeId || editingItem?.coachTypeId));
|
||||
const isBedType = selectedCoachType &&
|
||||
(selectedCoachType.name?.toLowerCase().includes('bed') ||
|
||||
selectedCoachType.name?.toLowerCase().includes('sleeper') ||
|
||||
selectedCoachType.type?.toLowerCase().includes('sleeper'));
|
||||
|
||||
const derivedBedCategory = editingItem?.isCoach && selectedCoachType
|
||||
? (selectedCoachType.name?.toLowerCase().includes('vip') ? 'VIP_BED' : 'ECONOMY_BED')
|
||||
: (editingItem?.bedCategory || '');
|
||||
|
||||
return isBedType ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="label">Bed Category</label>
|
||||
<select
|
||||
name="bedCategory"
|
||||
className="input"
|
||||
defaultValue={derivedBedCategory}
|
||||
>
|
||||
<option value="">Select bed category</option>
|
||||
<option value="ECONOMY_BED">Economy Bed</option>
|
||||
<option value="VIP_BED">VIP Bed</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Select if this is a bed coach
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Beds Per Room</label>
|
||||
<select
|
||||
name="bedsPerRoom"
|
||||
className="input"
|
||||
defaultValue={editingItem?.bedsPerRoom || ''}
|
||||
>
|
||||
<option value="">Auto (VIP: 4, Economy: 6)</option>
|
||||
<option value="2">2 beds per room</option>
|
||||
<option value="4">4 beds per room</option>
|
||||
<option value="6">6 beds per room</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Only applies to bed coaches
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null;
|
||||
})()}
|
||||
{isBedCoach && (
|
||||
<>
|
||||
<div>
|
||||
<label className="label">Bed Category *</label>
|
||||
<select
|
||||
name="bedCategory"
|
||||
className="input"
|
||||
defaultValue={editingItem?.bedCategory || ''}
|
||||
required
|
||||
>
|
||||
<option value="">Select bed category</option>
|
||||
<option value="ECONOMY_BED">Economy Bed (3 cols × 2 rows)</option>
|
||||
<option value="VIP_BED">VIP Bed (2 cols × 2 rows)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Beds Per Room</label>
|
||||
<select
|
||||
name="bedsPerRoom"
|
||||
className="input"
|
||||
defaultValue={editingItem?.bedsPerRoom || ''}
|
||||
>
|
||||
<option value="">Auto (VIP: 4, Economy: 6)</option>
|
||||
<option value="4">4 beds per room (VIP)</option>
|
||||
<option value="6">6 beds per room (Economy)</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="label">Arrangement *</label>
|
||||
@@ -903,6 +894,7 @@ export default function CoachesPage() {
|
||||
setShowModal(false);
|
||||
setEditingItem(null);
|
||||
setSelectedCoachTypeId('');
|
||||
setIsBedCoach(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -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<CurrencyRate | null>(null);
|
||||
const [rateInput, setRateInput] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [addForm, setAddForm] = useState({ code: '', name: '', symbol: '', exchangeRate: '' });
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<CurrencyRate | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: currencies = [], isLoading } = useQuery<CurrencyRate[]>({
|
||||
@@ -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)
|
||||
</p>
|
||||
</div>
|
||||
<ActionButton
|
||||
icon={RefreshCw}
|
||||
variant="secondary"
|
||||
onClick={() => syncMutation.mutate()}
|
||||
loading={syncMutation.isPending}
|
||||
>
|
||||
Sync Rates
|
||||
</ActionButton>
|
||||
<div className="flex gap-2">
|
||||
<ActionButton icon={Plus} onClick={() => { setError(null); setShowAddModal(true); }}>Add Currency</ActionButton>
|
||||
<ActionButton
|
||||
icon={RefreshCw}
|
||||
variant="secondary"
|
||||
onClick={() => syncMutation.mutate()}
|
||||
loading={syncMutation.isPending}
|
||||
>
|
||||
Sync Rates
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && !editingRate && (
|
||||
@@ -204,6 +227,68 @@ export default function CurrenciesPage() {
|
||||
<p>• Rates apply globally; changes take effect immediately on the next booking or fare quote</p>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => { setShowAddModal(false); setError(null); }}
|
||||
title="Add Currency"
|
||||
size="sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 p-3 rounded-lg text-sm text-red-800 dark:text-red-200">{error}</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Code *</label>
|
||||
<input className="input uppercase" placeholder="e.g., EUR" maxLength={5}
|
||||
value={addForm.code} onChange={(e) => setAddForm({ ...addForm, code: e.target.value.toUpperCase() })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Symbol *</label>
|
||||
<input className="input" placeholder="e.g., €"
|
||||
value={addForm.symbol} onChange={(e) => setAddForm({ ...addForm, symbol: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Name *</label>
|
||||
<input className="input" placeholder="e.g., Euro"
|
||||
value={addForm.name} onChange={(e) => setAddForm({ ...addForm, name: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Exchange Rate (1 ETB = ? {addForm.code || '...'}) *</label>
|
||||
<input type="number" min="0.0001" step="0.0001" className="input" placeholder="e.g., 0.018"
|
||||
value={addForm.exchangeRate} onChange={(e) => setAddForm({ ...addForm, exchangeRate: e.target.value })} />
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<ActionButton variant="secondary" onClick={() => { setShowAddModal(false); setError(null); }}>Cancel</ActionButton>
|
||||
<ActionButton
|
||||
loading={createMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!addForm.code || !addForm.name || !addForm.symbol || !addForm.exchangeRate) {
|
||||
setError('All fields are required'); return;
|
||||
}
|
||||
const rate = parseFloat(addForm.exchangeRate);
|
||||
if (isNaN(rate) || rate <= 0) { setError('Exchange rate must be a positive number'); return; }
|
||||
createMutation.mutate({ code: addForm.code, name: addForm.name, symbol: addForm.symbol, exchangeRate: rate });
|
||||
}}
|
||||
>
|
||||
Add Currency
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!deleteConfirm}
|
||||
onClose={() => setDeleteConfirm(null)}
|
||||
onConfirm={() => deleteMutation.mutate(deleteConfirm!.id)}
|
||||
title="Delete Currency"
|
||||
message={`Delete ${deleteConfirm?.code} (${CURRENCY_META[deleteConfirm?.code ?? '']?.name ?? deleteConfirm?.code})? This will remove the exchange rate record.`}
|
||||
confirmText="Delete"
|
||||
isDanger
|
||||
isLoading={deleteMutation.isPending}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={!!editingRate}
|
||||
onClose={() => { setEditingRate(null); setError(null); }}
|
||||
|
||||
@@ -214,21 +214,31 @@ export default function PackageBookingsPage() {
|
||||
<section>
|
||||
<SectionHeader title={`Passengers (${b.passengers.length})`} />
|
||||
<div className="divide-y divide-muted rounded-lg border border-muted overflow-hidden">
|
||||
{b.passengers.map((p: any, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between px-4 py-3 bg-muted/20 hover:bg-muted/40 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 h-6 rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-400 text-xs font-bold flex items-center justify-center shrink-0">{i + 1}</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{p.passengerName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{p.dateOfBirth ? new Date(p.dateOfBirth).toLocaleDateString() : ''}
|
||||
{p.idDocumentType ? ` · ${p.idDocumentType}` : ''}
|
||||
{p.passportNumber ? ` · ${p.passportNumber}` : ''}
|
||||
</p>
|
||||
{b.passengers.map((p: any, i: number) => {
|
||||
const isChild = i >= (b.adultCount ?? b.passengerCount);
|
||||
return (
|
||||
<div key={i} className="flex items-center justify-between px-4 py-3 bg-muted/20 hover:bg-muted/40 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 h-6 rounded-full bg-emerald-100 dark:bg-emerald-900/40 text-emerald-700 dark:text-emerald-400 text-xs font-bold flex items-center justify-center shrink-0">{i + 1}</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{p.passengerName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{p.dateOfBirth ? new Date(p.dateOfBirth).toLocaleDateString() : ''}
|
||||
{p.idDocumentType ? ` · ${p.idDocumentType}` : ''}
|
||||
{p.passportNumber ? ` · ${p.passportNumber}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${
|
||||
isChild
|
||||
? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400'
|
||||
: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400'
|
||||
}`}>
|
||||
{isChild ? 'CHILD' : 'ADULT'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -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<any>(null);
|
||||
const [tiersPackage, setTiersPackage] = useState<any>(null);
|
||||
const [editingTier, setEditingTier] = useState<any>(null);
|
||||
const [tierForm, setTierForm] = useState({ seatType: '', label: '', priceMinor: '', availableSeats: '' });
|
||||
const [tierForm, setTierForm] = useState({ seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' });
|
||||
const [deleteTierConfirm, setDeleteTierConfirm] = useState<any>(null);
|
||||
const [tierError, setTierError] = useState<string | null>(null);
|
||||
const [deletePackageConfirm, setDeletePackageConfirm] = useState<any>(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) => (
|
||||
<div key={t.id} className="flex items-center justify-between rounded border border-border px-3 py-2">
|
||||
<div>
|
||||
<span className="font-medium text-sm">{t.label}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">({t.seatType})</span>
|
||||
<span className="font-medium text-sm">{t.seatType}</span>
|
||||
{t.seatClassId && (
|
||||
<span className="ml-2 text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded font-medium">
|
||||
{seatClasses.find((sc: any) => sc.id === t.seatClassId)?.coachType.type ?? 'Linked'}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{formatCurrency(t.priceMinor, 'ETB')} · {t.bookedSeats}/{t.availableSeats} booked
|
||||
</div>
|
||||
@@ -454,16 +470,31 @@ export default function PackagesPage() {
|
||||
<div className="border-t border-border pt-4">
|
||||
<p className="text-sm font-semibold mb-3">{editingTier ? 'Edit Tier' : 'Add New Tier'}</p>
|
||||
<form onSubmit={handleTierSubmit} className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Seat Type *</label>
|
||||
<input className="input" placeholder="e.g., HSC" required
|
||||
value={tierForm.seatType} onChange={(e) => setTierForm((f) => ({ ...f, seatType: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Label *</label>
|
||||
<input className="input" placeholder="e.g., Regular Seat (HSC)" required
|
||||
value={tierForm.label} onChange={(e) => setTierForm((f) => ({ ...f, label: e.target.value }))} />
|
||||
<div className="col-span-2">
|
||||
<label className="label">Seat Class *</label>
|
||||
<select
|
||||
className="input"
|
||||
required
|
||||
value={tierForm.seatClassId}
|
||||
onChange={(e) => {
|
||||
const sc = seatClasses.find((c: any) => c.id === e.target.value);
|
||||
setTierForm((f) => ({
|
||||
...f,
|
||||
seatClassId: e.target.value,
|
||||
seatType: sc?.name ?? f.seatType,
|
||||
label: sc?.name ?? f.label,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<option value="">Select seat class</option>
|
||||
{seatClasses.map((sc: any) => (
|
||||
<option key={sc.id} value={sc.id}>
|
||||
{sc.name}{sc.description ? ` — ${sc.description}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Price (minor/cents) *</label>
|
||||
<input type="number" min="0" className="input" placeholder="e.g., 1023200" required
|
||||
@@ -476,7 +507,7 @@ export default function PackagesPage() {
|
||||
</div>
|
||||
<div className="col-span-2 flex justify-end gap-2">
|
||||
{editingTier && (
|
||||
<ActionButton type="button" variant="secondary" onClick={() => { setEditingTier(null); setTierForm({ seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); }}>Cancel</ActionButton>
|
||||
<ActionButton type="button" variant="secondary" onClick={() => { setEditingTier(null); setTierForm({ seatClassId: '', seatType: '', label: '', priceMinor: '', availableSeats: '' }); setTierError(null); }}>Cancel</ActionButton>
|
||||
)}
|
||||
<ActionButton type="submit" loading={addTierMutation.isPending || updateTierMutation.isPending}>
|
||||
{editingTier ? 'Update Tier' : 'Add Tier'}
|
||||
|
||||
@@ -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) => (
|
||||
<span className={`edr-badge ${statusMap[schedule.status] || 'edr-badge-info'}`}>
|
||||
{schedule.status}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`edr-badge ${statusMap[schedule.status] || 'edr-badge-info'}`}>
|
||||
{schedule.status}
|
||||
</span>
|
||||
{schedule.isPackageOnly && (
|
||||
<span className="edr-badge edr-badge-warning">PKG</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
] as any;
|
||||
@@ -1040,6 +1049,20 @@ export default function SchedulesPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border border-border">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isPackageOnly"
|
||||
checked={editForm.isPackageOnly}
|
||||
onChange={(e) => setEditForm({ ...editForm, isPackageOnly: e.target.checked })}
|
||||
className="w-4 h-4 rounded"
|
||||
/>
|
||||
<label htmlFor="isPackageOnly" className="text-sm cursor-pointer">
|
||||
<span className="font-medium">Package Only</span>
|
||||
<span className="block text-xs text-muted-foreground">Hide from public search — reserved for package bookings</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="label">Coaches (Optional)</label>
|
||||
|
||||
@@ -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() {
|
||||
</h2>
|
||||
{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)
|
||||
|
||||
@@ -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<string, { coachTypeId: string; coachTypeName: string; coachTypeCode: string; coachTypeType: string; tiers: PriceTier[] }>();
|
||||
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<string | null>(null);
|
||||
const priceMultiplier = isRoundTrip ? 2 : 1;
|
||||
const groups = groupTiersByCoachType(tiers ?? []);
|
||||
|
||||
if (!tiers?.length) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-gray-100 dark:border-gray-800">
|
||||
<p className="text-sm text-gray-400 text-center py-4">No price tiers available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-gray-100 dark:border-gray-800">
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white mb-4">
|
||||
Select Seat Type
|
||||
</h2>
|
||||
|
||||
{!tiers?.length ? (
|
||||
<p className="text-sm text-gray-400 text-center py-4">
|
||||
No price tiers available
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2.5">
|
||||
{tiers.map((tier) => {
|
||||
const soldOut = tier.availableSeats === 0;
|
||||
const selected = tier.id === selectedTierId;
|
||||
return (
|
||||
<div
|
||||
key={tier.id}
|
||||
onClick={() => !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 */}
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div
|
||||
className={`w-4 h-4 rounded-full border-2 flex-shrink-0 flex items-center justify-center mt-0.5 transition-colors ${
|
||||
selected
|
||||
? "border-primary bg-primary"
|
||||
: "border-gray-300 dark:border-gray-600"
|
||||
}`}
|
||||
>
|
||||
{selected && <div className="w-1.5 h-1.5 rounded-full bg-white" />}
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-white leading-snug">
|
||||
{tier.label.trim()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Row 2: seatType badge + seats + price */}
|
||||
<div className="flex items-center justify-between mt-2 pl-[26px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold text-gray-400 bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">
|
||||
{tier.seatType.trim()}
|
||||
</span>
|
||||
{soldOut ? (
|
||||
<span className="text-[10px] font-bold text-red-500 bg-red-50 dark:bg-red-900/20 px-1.5 py-0.5 rounded">
|
||||
SOLD OUT
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{tier.availableSeats} left
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-extrabold text-primary">
|
||||
{formatPrice(tier.priceMinor * priceMultiplier, tier.currency)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Book Now — shown only when selected */}
|
||||
{selected && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onBookNow(); }}
|
||||
className="mt-3.5 w-full py-3 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-md flex items-center justify-center gap-2"
|
||||
>
|
||||
Book Now <ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-2xl p-5 border border-gray-100 dark:border-gray-800 space-y-3">
|
||||
<h2 className="text-base font-bold text-gray-900 dark:text-white">Select Coach Type</h2>
|
||||
{groups.map((group) => {
|
||||
const CoachIcon = getCoachIcon(group.coachTypeType);
|
||||
const allSoldOut = group.tiers.every((t) => t.availableSeats === 0);
|
||||
const isSelected = selectedId === group.coachTypeId;
|
||||
return (
|
||||
<div
|
||||
key={group.coachTypeId}
|
||||
className={`rounded-xl border-2 overflow-hidden transition-colors ${
|
||||
allSoldOut
|
||||
? 'border-gray-200 dark:border-gray-700 opacity-50'
|
||||
: isSelected
|
||||
? 'border-primary'
|
||||
: 'border-gray-200 dark:border-gray-700 cursor-pointer hover:border-primary/50'
|
||||
}`}
|
||||
onClick={() => !allSoldOut && setSelectedId(isSelected ? null : group.coachTypeId)}
|
||||
>
|
||||
{/* Coach type header */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 bg-gray-50 dark:bg-gray-800/60">
|
||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 ${
|
||||
isSelected ? 'bg-primary' : 'bg-primary/10'
|
||||
}`}>
|
||||
<CoachIcon className={`w-5 h-5 ${isSelected ? 'text-white' : 'text-primary'}`} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-bold text-gray-900 dark:text-white">
|
||||
{formatCoachTypeLabel(group.coachTypeType)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
From {formatPrice(group.minPrice * priceMultiplier, group.currency)}
|
||||
{allSoldOut && <span className="ml-2 text-red-500 font-semibold">· Sold out</span>}
|
||||
</p>
|
||||
</div>
|
||||
{!allSoldOut && (
|
||||
<div className={`w-5 h-5 rounded-full border-2 flex-shrink-0 flex items-center justify-center ${
|
||||
isSelected ? 'border-primary bg-primary' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}>
|
||||
{isSelected && <Check className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* All available classes for this coach type */}
|
||||
<div className="px-4 py-3 space-y-2">
|
||||
{group.tiers.map((tier) => {
|
||||
const soldOut = tier.availableSeats === 0;
|
||||
return (
|
||||
<div
|
||||
key={tier.id}
|
||||
className={`flex items-start gap-2 py-1.5 ${soldOut ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary flex-shrink-0 mt-1.5" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">{tier.seatType.trim()}</p>
|
||||
<p className="text-xs">
|
||||
<span className="font-bold text-primary">{formatPrice(tier.priceMinor * priceMultiplier, tier.currency)}</span>
|
||||
{soldOut ? (
|
||||
<span className="ml-2 font-bold text-red-500">Sold out</span>
|
||||
) : (
|
||||
<span className="ml-2 text-gray-400">{tier.availableSeats} left</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Book Now — only when this group is selected */}
|
||||
{isSelected && !allSoldOut && (
|
||||
<div className="px-4 pb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onBookNow(group.coachTypeId); }}
|
||||
className="w-full py-2.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white font-bold text-sm rounded-xl transition-all shadow-md flex items-center justify-center gap-2"
|
||||
>
|
||||
Book Now <ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-3 bg-primary/5 border-b border-primary/10">
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide font-semibold">Selected tier</p>
|
||||
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">{tier.label.trim()}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult</p>
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wide font-semibold">Coach type</p>
|
||||
<p className="text-sm font-bold text-gray-900 dark:text-white mt-0.5">{tier.seatClass?.coachType?.type ? formatCoachTypeLabel(tier.seatClass.coachType.type) : tier.label.trim()}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{remaining} seats remaining · prices from {formatPrice(tier.priceMinor * priceMultiplier, tier.currency)} per adult · actual class chosen on seat map</p>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-5 space-y-4">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<div key={label} className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -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<string | null>(null);
|
||||
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string | null>(null);
|
||||
const [passengerModalOpen, setPassengerModalOpen] = useState(false);
|
||||
const [bookingContextLoading, setBookingContextLoading] = useState(false);
|
||||
const [bookingContextError, setBookingContextError] = useState<string | null>(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 (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
{/* Passenger count modal */}
|
||||
{passengerModalOpen && selectedTier && (
|
||||
{passengerModalOpen && representativeTier && (
|
||||
<PassengerCountModal
|
||||
tier={selectedTier}
|
||||
tier={representativeTier}
|
||||
onClose={() => { setPassengerModalOpen(false); setBookingContextError(null); }}
|
||||
onConfirm={handleBookNow}
|
||||
loading={bookingContextLoading}
|
||||
@@ -751,9 +822,7 @@ export default function PackageDetailPage() {
|
||||
<div className="lg:hidden">
|
||||
<PriceTiersPanel
|
||||
tiers={pkg.priceTiers}
|
||||
selectedTierId={selectedTierId}
|
||||
onSelect={setSelectedTierId}
|
||||
onBookNow={() => setPassengerModalOpen(true)}
|
||||
onBookNow={(coachTypeId) => { setSelectedCoachTypeId(coachTypeId); setPassengerModalOpen(true); }}
|
||||
isRoundTrip={isRoundTripPkg}
|
||||
/>
|
||||
</div>
|
||||
@@ -764,9 +833,7 @@ export default function PackageDetailPage() {
|
||||
<div className="sticky top-20">
|
||||
<PriceTiersPanel
|
||||
tiers={pkg.priceTiers}
|
||||
selectedTierId={selectedTierId}
|
||||
onSelect={setSelectedTierId}
|
||||
onBookNow={() => setPassengerModalOpen(true)}
|
||||
onBookNow={(coachTypeId) => { setSelectedCoachTypeId(coachTypeId); setPassengerModalOpen(true); }}
|
||||
isRoundTrip={isRoundTripPkg}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user