From cd4df2f810b63cbf9d73fc217a6e074eadf34b8b Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Thu, 9 Jul 2026 10:32:19 +0300 Subject: [PATCH 1/2] Update webhooks.controller.ts --- .../modules/webhooks/webhooks.controller.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts index 66232dfcc..f1e4fa6b0 100644 --- a/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts +++ b/apps/edr-payment-api/src/modules/webhooks/webhooks.controller.ts @@ -51,8 +51,22 @@ export class WebhooksController { @ApiOperation({ summary: "Telebirr payment notification callback (Ethiopia)", }) - async receiveTelebirr(@Body() payload: TelebirrWebhookPayload) { - this.logger.log("Telebirr webhook called"); + async receiveTelebirr( + @Body() payload: TelebirrWebhookPayload, + @Headers() headers: Record, + @Req() req: { method?: string; rawBody?: Buffer }, + ) { + this.logger.log( + `Telebirr webhook hit: method=${req.method ?? "n/a"} ` + + `merchOrderId=${payload?.merch_order_id ?? "n/a"} ` + + `paymentOrderId=${payload?.payment_order_id ?? "n/a"} ` + + `tradeStatus=${payload?.trade_status ?? "n/a"}`, + ); + this.logger.log(`Telebirr webhook headers: ${JSON.stringify(headers)}`); + this.logger.log(`Telebirr webhook payload: ${JSON.stringify(payload)}`); + this.logger.log( + `Telebirr webhook raw body: ${req.rawBody?.toString("utf8") ?? "(none)"}`, + ); try { await this.telebirr.handle(payload); } catch (err) { From 554756a116d19d825b339c50a4a50f01a7fa6a40 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Thu, 9 Jul 2026 11:00:56 +0300 Subject: [PATCH 2/2] Luggage processing,, schedule times and tariff related updates --- .../src/modules/agents/agents.controller.ts | 8 +- .../src/modules/agents/agents.service.ts | 7 + .../excess-baggage.controller.ts | 41 +++--- .../excess-baggage/excess-baggage.dto.ts | 3 +- .../excess-baggage/excess-baggage.service.ts | 2 +- .../src/modules/search/search.service.ts | 106 +++++++++----- .../backoffice/src/app/agents/page.tsx | 34 ++++- .../src/app/excess-baggage/page.tsx | 131 +++++++++++++++++- .../backoffice/src/app/schedules/page.tsx | 9 +- .../backoffice/src/app/tariff-rates/page.tsx | 17 +-- .../backoffice/src/app/tickets/page.tsx | 21 +-- .../backoffice/src/lib/api/index.ts | 1 + .../backoffice/src/lib/utils.ts | 6 +- .../src/app/booking/passengers/page.tsx | 46 +++++- .../portal/src/app/booking/results/page.tsx | 129 +++++------------ 15 files changed, 365 insertions(+), 196 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts index d8be18e74..1f7fcd288 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AgentsService } from './agents.service'; import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto'; @@ -34,6 +34,12 @@ export class AgentsController { updateAgent(@Param('id') id: string, @Body() dto: Partial & { active?: boolean }) { return this.service.updateAgent(id, dto); } + + @Delete(':id') + @ApiOperation({ summary: 'Delete agent profile' }) + deleteAgent(@Param('id') id: string) { + return this.service.deleteAgent(id); + } @Post('bookings') @ApiOperation({ summary: 'Create agent booking with cash payment' }) createBooking(@Body() dto: CreateAgentBookingDto) { diff --git a/apps/edr-passenger-api/src/modules/agents/agents.service.ts b/apps/edr-passenger-api/src/modules/agents/agents.service.ts index cd17da441..4ba7afcf1 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.service.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.service.ts @@ -210,4 +210,11 @@ export class AgentsService { }, }); } + + async deleteAgent(id: string) { + const agent = await this.prisma.agent.findUnique({ where: { id } }); + if (!agent) throw new NotFoundException('Agent not found'); + await this.prisma.agent.delete({ where: { id } }); + return { deleted: true }; + } } diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts index 8d01e7113..551cc8881 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { IsInt, IsPositive, IsString } from 'class-validator'; import { ExcessBaggageService } from './excess-baggage.service'; @@ -26,7 +26,8 @@ export class ExcessBaggageAgentController { @Post() @ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' }) - logCharge(@Body() dto: LogExcessBaggageDto) { + logCharge(@Request() req: any, @Body() dto: LogExcessBaggageDto) { + dto.agentId = req.user?.id ?? req.user?.sub ?? dto.agentId; return this.service.logCharge(dto); } @@ -50,24 +51,6 @@ export class ExcessBaggageAgentController { }); } - @Get(':id') - @ApiOperation({ summary: 'Get a single charge by ID (agent polling)' }) - getCharge(@Param('id') id: string) { - return this.service.getCharge(id); - } - - @Post(':id/resend') - @ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' }) - resendLink(@Param('id') id: string) { - return this.service.resendLink(id); - } - - @Patch(':id/waive') - @ApiOperation({ summary: 'Waive a charge (supervisor only)' }) - waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) { - return this.service.waiveCharge(id, dto); - } - @Get('allowances') @ApiOperation({ summary: 'List all baggage allowance rules' }) getAllowances() { @@ -92,6 +75,24 @@ export class ExcessBaggageAgentController { return this.service.deleteAllowance(id); } + @Get(':id') + @ApiOperation({ summary: 'Get a single charge by ID (agent polling)' }) + getCharge(@Param('id') id: string) { + return this.service.getCharge(id); + } + + @Post(':id/resend') + @ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' }) + resendLink(@Param('id') id: string) { + return this.service.resendLink(id); + } + + @Patch(':id/waive') + @ApiOperation({ summary: 'Waive a charge (supervisor only)' }) + waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) { + return this.service.waiveCharge(id, dto); + } + @Delete(':id') @ApiOperation({ summary: 'Delete excess baggage charge (admin only)' }) deleteCharge(@Param('id') id: string) { diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts index 58bf2bf84..4379ae28d 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.dto.ts @@ -3,7 +3,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class LogExcessBaggageDto { @ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string; - @ApiProperty({ example: 'agent-uuid' }) @IsString() agentId: string; + @ApiPropertyOptional({ example: 'agent-uuid', description: 'Injected from IAM token; optional override' }) + @IsOptional() @IsString() agentId?: string; @ApiProperty({ example: 7, description: 'Excess weight in kg above the free allowance' }) @IsInt() @IsPositive() excessWeightKg: number; @ApiPropertyOptional({ description: 'Collect cash now instead of sending a payment link' }) diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts index 24e09b87c..30ab0fd5e 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts @@ -75,7 +75,7 @@ export class ExcessBaggageService { const charge = await this.prisma.excessBaggageCharge.create({ data: { bookingId: dto.bookingId, - agentId: dto.agentId, + agentId: dto.agentId ?? '', excessWeightKg: dto.excessWeightKg, feePerKgMinor, totalMinor, 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 4de0f0d21..a28be295e 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -154,43 +154,54 @@ export class SearchService { ) { const [y, m, d] = dateStr.split('-').map(Number); const requestedDate = new Date(y, m - 1, d, 0, 0, 0, 0); - - const now = new Date(); - const daysBefore = Math.min(7, Math.floor(requestedDate.getTime() / 86_400_000)); - const daysAfter = 14 - daysBefore; - - const windowStart = new Date(requestedDate); - windowStart.setDate(windowStart.getDate() - daysBefore); - if (windowStart < now) windowStart.setTime(now.getTime()); - - const windowEnd = new Date(requestedDate); - windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); - - const totalPassengers = adultCount + (childCount ?? 0); - const requestedNextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0); + const now = new Date(); + const totalPassengers = adultCount + (childCount ?? 0); + const NEEDED = 3; - const schedules = await this.prisma.trainSchedule.findMany({ - where: { - status: 'SCHEDULED', - isPackageOnly: false, - OR: [ - { departureAt: { gte: windowStart, lt: requestedDate } }, - { departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } }, - ], - stopTimes: { some: { stationId: originStationId } }, - coachAssignments: { some: {} }, - }, - include: SCHEDULE_INCLUDE, - orderBy: { departureAt: 'asc' }, - }); + const baseWhere = { + status: 'SCHEDULED', + isPackageOnly: false, + stopTimes: { some: { stationId: originStationId } }, + coachAssignments: { some: {} }, + } as const; - const results = await Promise.all( - schedules.map(schedule => - this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality) - ) - ); - return results.filter((r): r is NonNullable => !!r && r.hasAvailability); + // Fetch candidates before and after in parallel; take more than needed to + // account for routes that don't serve the destination or have no availability. + const FETCH_LIMIT = NEEDED * 5; + + const [beforeCandidates, afterCandidates] = await Promise.all([ + this.prisma.trainSchedule.findMany({ + where: { ...baseWhere, departureAt: { gte: now < requestedDate ? now : new Date(0), lt: requestedDate } }, + include: SCHEDULE_INCLUDE, + orderBy: { departureAt: 'desc' }, + take: FETCH_LIMIT, + }), + this.prisma.trainSchedule.findMany({ + where: { ...baseWhere, departureAt: { gte: requestedNextDay > now ? requestedNextDay : now } }, + include: SCHEDULE_INCLUDE, + orderBy: { departureAt: 'asc' }, + take: FETCH_LIMIT, + }), + ]); + + const pickN = async (candidates: typeof beforeCandidates, limit: number) => { + const out: NonNullable>>[] = []; + for (const schedule of candidates) { + if (out.length >= limit) break; + const r = await this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality); + if (r?.hasAvailability) out.push(r); + } + return out; + }; + + const [before, after] = await Promise.all([ + pickN(beforeCandidates, NEEDED), + pickN(afterCandidates, NEEDED), + ]); + + // before was fetched desc (closest first); reverse so result is chronological + return [...before.reverse(), ...after]; } private async searchSchedules( @@ -415,7 +426,7 @@ export class SearchService { } } - const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass); + const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass, nationality); const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; @@ -638,6 +649,10 @@ export class SearchService { ): Promise> { const displayCurrency = resolveCurrencyFromNationality(nationality); + const nationalityUpper = (nationality ?? '').toUpperCase(); + const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN') + ? 'LOCAL' : 'INTERNATIONAL'; + // Collect seat class IDs from the schedule include for the ID set, // but fetch fresh records from DB so updated baseFareMinor is always current const seatClassIdSet = new Set(); @@ -647,7 +662,14 @@ export class SearchService { } } const freshSeatClasses = await this.prisma.seatClass.findMany({ - where: { id: { in: Array.from(seatClassIdSet) }, isActive: true }, + where: { + id: { in: Array.from(seatClassIdSet) }, + isActive: true, + OR: [ + { nationalityType: null }, + { nationalityType: nationalityType }, + ], + }, }); const seatClassMap = new Map(freshSeatClasses.map(sc => [sc.id, sc])); const seatClasses = freshSeatClasses.sort((a, b) => a.baseFareMinor - b.baseFareMinor); @@ -725,6 +747,7 @@ export class SearchService { private buildCoachTypeDetails( schedule: ScheduleWithIncludes, faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>, + nationality?: string, ): Array<{ coachTypeId: string; coachTypeName: string; @@ -749,8 +772,16 @@ export class SearchService { }); } + const nationalityUpper = (nationality ?? '').toUpperCase(); + const resolvedNationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN') + ? 'LOCAL' : 'INTERNATIONAL'; + const entry = coachTypeMap.get(coachType.id)!; - coachType.seatClasses?.forEach((sc: any) => entry.classNames.add(sc.name)); + coachType.seatClasses?.forEach((sc: any) => { + // Exclude classes that belong to the wrong nationality type + if (sc.nationalityType && sc.nationalityType !== resolvedNationalityType) return; + if (faresByClass.some(f => f.seatClassName === sc.name)) entry.classNames.add(sc.name); + }); } const result = []; @@ -769,6 +800,7 @@ export class SearchService { .filter((c): c is { name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => c !== null) .sort((a, b) => a.baseFareMinor - b.baseFareMinor); + if (classes.length === 0) continue; result.push({ coachTypeId: coachType.id, coachTypeName: coachType.name, diff --git a/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx index 39e02dc3c..9efd7ab19 100644 --- a/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/agents/page.tsx @@ -2,11 +2,12 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Plus, Edit, Eye } from 'lucide-react'; +import { Plus, Edit, Eye, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Badge from '@/components/ui/Badge'; import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { agentsApi, apiClient } from '@/lib/api'; import { formatCurrency, formatDateTime } from '@/lib/utils'; import { useAuthStore } from '@/lib/auth-store'; @@ -48,6 +49,19 @@ export default function AgentsPage() { const [editingAgent, setEditingAgent] = useState(null); const [editError, setEditError] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; agent: any | null }>({ isOpen: false, agent: null }); + const [deleteError, setDeleteError] = useState(null); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => agentsApi.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['agents'] }); + setDeleteConfirm({ isOpen: false, agent: null }); + setDeleteError(null); + }, + onError: (e: any) => setDeleteError(e?.response?.data?.message || e?.message || 'Failed to delete agent'), + }); + const editMutation = useMutation({ mutationFn: ({ id, ...data }: any) => apiClient.patch(`/agents/${id}`, data), onSuccess: () => { @@ -122,6 +136,12 @@ export default function AgentsPage() { variant: 'secondary' as const, icon: Eye, }, + { + label: 'Delete', + onClick: (agent: any) => { setDeleteError(null); setDeleteConfirm({ isOpen: true, agent }); }, + variant: 'danger' as const, + icon: Trash2, + }, ]; return ( @@ -169,6 +189,18 @@ export default function AgentsPage() { emptyMessage="No agents found" /> + { setDeleteConfirm({ isOpen: false, agent: null }); setDeleteError(null); }} + onConfirm={async () => { if (deleteConfirm.agent) await deleteMutation.mutateAsync(deleteConfirm.agent.id); }} + title="Delete Agent" + message={`Are you sure you want to delete agent ${deleteConfirm.agent?.agentCode}? This action cannot be undone.`} + confirmText="Delete" + isDanger + isLoading={deleteMutation.isPending} + error={deleteError ?? undefined} + /> + {/* Agent Details Modal */} setSelected(null)} title="Agent Details" size="xl"> {selected && (() => { diff --git a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx index 7b5db209a..3c13e952b 100644 --- a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx @@ -2,13 +2,14 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { RefreshCw, Send, Trash2 } from 'lucide-react'; +import { Plus, RefreshCw, Send, Trash2 } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; import { excessBaggageApi } from '@/lib/api'; import { formatDateTime, formatCurrency } from '@/lib/utils'; +import { useAuthStore } from '@/lib/auth-store'; const STATUS_VARIANT: Record = { PENDING: 'PENDING', @@ -22,9 +23,16 @@ export default function ExcessBaggagePage() { const queryClient = useQueryClient(); const [filters, setFilters] = useState({ status: '', bookingRef: '', dateFrom: '', dateTo: '', page: '1' }); const [showExtraFilters, setShowExtraFilters] = useState(false); + const user = useAuthStore((s) => s.user); const [waiveModal, setWaiveModal] = useState(null); const [waiveReason, setWaiveReason] = useState(''); const [waiveError, setWaiveError] = useState(null); + const [logModal, setLogModal] = useState(false); + const [logForm, setLogForm] = useState({ bookingId: '', excessWeightKg: '', collectCash: false }); + const [logError, setLogError] = useState(null); + const [resendModal, setResendModal] = useState(null); + const [resendSuccess, setResendSuccess] = useState(false); + const [resendError, setResendError] = useState(null); const { data, isLoading } = useQuery({ queryKey: ['excess-baggage', filters], @@ -37,6 +45,17 @@ export default function ExcessBaggagePage() { }), }); + const logMutation = useMutation({ + mutationFn: (data: any) => excessBaggageApi.logCharge(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }); + setLogModal(false); + setLogForm({ bookingId: '', excessWeightKg: '', collectCash: false }); + setLogError(null); + }, + onError: (e: any) => setLogError(e?.response?.data?.message || e?.message || 'Failed to log charge'), + }); + const waiveMutation = useMutation({ mutationFn: ({ id, reason }: { id: string; reason: string }) => excessBaggageApi.waive(id, { waivedBy: 'supervisor', waivedReason: reason }), @@ -51,7 +70,12 @@ export default function ExcessBaggagePage() { const resendMutation = useMutation({ mutationFn: (id: string) => excessBaggageApi.resendLink(id), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['excess-baggage'] }); + setResendSuccess(true); + setResendError(null); + }, + onError: (e: any) => setResendError(e?.response?.data?.message || e?.message || 'Failed to resend link'), }); const deleteMutation = useMutation({ @@ -115,7 +139,7 @@ export default function ExcessBaggagePage() { label: 'Resend Link', icon: Send, variant: 'secondary' as const, - onClick: (c: any) => resendMutation.mutate(c.id), + onClick: (c: any) => { setResendModal(c); setResendSuccess(false); setResendError(null); }, show: (c: any) => c.status === 'PENDING', }, { @@ -145,6 +169,9 @@ export default function ExcessBaggagePage() {

Excess Lugagge

Track and manage excess luggage charges at boarding

+ { setLogModal(true); setLogError(null); setLogForm({ bookingId: '', excessWeightKg: '', collectCash: false }); }}> + Log Excess Luggage +
@@ -194,6 +221,104 @@ export default function ExcessBaggagePage() { emptyMessage="No excess baggage charges found" /> + {/* Log Excess Luggage Modal */} + setLogModal(false)} title="Log Excess Luggage" size="sm"> +
+ {user && ( +
+ Logging as agent: {user.fullName} +
+ )} +
+ + setLogForm({ ...logForm, bookingId: e.target.value })} + /> +
+
+ + setLogForm({ ...logForm, excessWeightKg: e.target.value })} + /> +
+ + {!logForm.collectCash && ( +

+ A payment link will be sent to the passenger's email and phone on file. +

+ )} + {logError &&

{logError}

} +
+ setLogModal(false)}>Cancel + { + if (!logForm.bookingId.trim() || !logForm.excessWeightKg) { + setLogError('Booking ID and excess weight are required'); + return; + } + logMutation.mutate({ + bookingId: logForm.bookingId.trim(), + excessWeightKg: parseInt(logForm.excessWeightKg), + collectCash: logForm.collectCash, + }); + }} + > + {logForm.collectCash ? 'Log & Collect Cash' : 'Log & Send Payment Link'} + +
+
+
+ + {/* Resend Link Modal */} + setResendModal(null)} title="Resend Payment Link" size="sm"> + {resendModal && ( +
+ {resendSuccess ? ( +
+ ✓ Payment link resent successfully. Expiry extended by 20 minutes. +
+ ) : ( + <> +

+ Resend payment link for booking{' '} + {resendModal.booking?.bookingRef}? +

+
+ {resendModal.contactPhone &&
📱 {resendModal.contactPhone}
} + {resendModal.contactEmail &&
✉ {resendModal.contactEmail}
} +
+

Amount: {formatCurrency(resendModal.totalMinor, resendModal.currency)}. Expiry will be extended by 20 minutes.

+ {resendError &&

{resendError}

} + + )} +
+ setResendModal(null)}>Close + {!resendSuccess && ( + resendMutation.mutate(resendModal.id)}> + Resend + + )} +
+
+ )} +
+ {/* Waive Modal */} ( - {new Date(schedule.departureAt).toLocaleString()} + {formatDateTime(schedule.departureAt)} ), }, { @@ -448,7 +449,7 @@ export default function SchedulesPage() { label: 'Arrival', sortable: true, render: (schedule: Schedule) => ( - {new Date(schedule.arrivalAt).toLocaleString()} + {formatDateTime(schedule.arrivalAt)} ), }, { @@ -641,7 +642,7 @@ export default function SchedulesPage() { } }} title="Cancel Schedule" - message={`Cancel the schedule departing ${cancelConfirm.item ? new Date(cancelConfirm.item.departureAt).toLocaleString() : ''}? Passengers with bookings will need to be notified separately.`} + message={`Cancel the schedule departing ${cancelConfirm.item ? formatDateTime(cancelConfirm.item.departureAt) : ''}? Passengers with bookings will need to be notified separately.`} confirmText="Cancel Schedule" isDanger={true} isLoading={cancelScheduleMutation.isPending} @@ -656,7 +657,7 @@ export default function SchedulesPage() { deleteConfirm.isBulk ? `Are you sure you want to delete ${Array.isArray(deleteConfirm.item) ? deleteConfirm.item.length : 0} schedule(s)? This action cannot be undone.` : `Are you sure you want to delete this schedule departing on ${ - deleteConfirm.item ? new Date(deleteConfirm.item.departureAt).toLocaleString() : '' + deleteConfirm.item ? formatDateTime(deleteConfirm.item.departureAt) : '' }?` } confirmText="Delete" diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx index 653ffddc3..33f39d87e 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx @@ -169,7 +169,7 @@ export default function TariffRatesPage() { render: (c: any) => {c.coachType?.name || c.coachTypeId}, }, { - key: 'bedPosition', label: 'Berth Position', + key: 'bedPosition', label: 'Bed Position', render: (c: any) => c.bedPosition ? {c.bedPosition} : Standard, @@ -215,8 +215,9 @@ export default function TariffRatesPage() { }, ]; - const selectedCoachType = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId); - const isBedCoach = selectedCoachType?.code === 'HBC' || selectedCoachType?.code === 'SBC'; + const selectedCoachType = coachTypesArray.find((c: any) => c.id === selectedCoachTypeId) + ?? editingClass?.coachType; + const isBedCoach = selectedCoachType?.name?.toLowerCase().includes('bed') || selectedCoachType?.code?.toLowerCase().includes('bed'); return (
@@ -224,7 +225,7 @@ export default function TariffRatesPage() {

Tariff Rates

- Manage per-km fare rates by nationality, coach type, and berth position per the official EDR tariff policy + Manage per-km fare rates by nationality, coach type, and bed position per the official EDR tariff policy

{ setEditingClass(null); setFormError(null); setShowModal(true); }}> @@ -237,7 +238,7 @@ export default function TariffRatesPage() { setSearch(e.target.value)} @@ -311,17 +312,17 @@ export default function TariffRatesPage() { {isBedCoach && (
- + + > + + {COUNTRIES.map((c) => ( + + ))} + {errors.passengers?.[index]?.passportCountry && (

{errors.passengers[index]?.passportCountry?.message}

)} @@ -1336,8 +1370,12 @@ export default function PassengersPage() { + {errors.passengers?.[index]?.passportExpiryDate && ( +

{errors.passengers[index]?.passportExpiryDate?.message}

+ )}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index 66f91dd60..8639319de 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -197,11 +197,11 @@ export default function ResultsPage() { // Alternatives are surfaced whenever a leg returns no exact-date results. const alternativeOutbound: Schedule[] = !!results && outboundSchedules.length === 0 - ? results?.alternativeOutbound || [] + ? results?.alternativeOutbound || results?.outboundAlternatives || [] : []; const alternativeInbound: Schedule[] = isRoundTrip && !!results && inboundSchedules.length === 0 - ? results?.alternativeInbound || [] + ? results?.alternativeInbound || results?.inboundAlternatives || [] : []; const requestedDate: string = (results && results.requestedDate) || searchData.date; @@ -927,28 +927,19 @@ export default function ResultsPage() { !!results && outboundSchedules.length === 0 && inboundSchedules.length === 0 && - alternativeOutbound.length === 0 && - alternativeInbound.length === 0; + (results?.alternativeOutbound || []).length === 0 && + (results?.alternativeInbound || []).length === 0; if (isRoundTripNoResults) { return (
-
-
-
- +
+
+
+ + No trains found for your selected dates or route.
-

- No trains found -

-

- We couldn't find any trains for your trip. Try adjusting - your dates or route. -

-
@@ -969,24 +960,13 @@ export default function ResultsPage() { {renderClassModal()}
-
-
- +
+
+ + No trains available on {requestedDateLabel}.
-

- No trains available -

-

- No trains are available on{" "} - - {requestedDateLabel} - -

-
@@ -1017,22 +997,13 @@ export default function ResultsPage() { return (
-
-
-
- +
+
+
+ + No trains found matching your search. Try adjusting your dates or route.
-

- No trains found -

-

- We couldn't find any trains matching your search criteria.{" "} -
Try adjusting your dates or route. -

-
@@ -1156,29 +1127,13 @@ export default function ResultsPage() { {outboundSchedules.length === 0 && alternativeOutbound.length > 0 && (
-
-
- +
+
+ + No trains on {requestedDate ? format(new Date(`${requestedDate}T00:00:00`), "EEEE, MMMM d") : "your selected date"}.
-

- No trains available -

-

- No trains are available on{" "} - - {requestedDate - ? format( - new Date(`${requestedDate}T00:00:00`), - "EEEE, MMMM d, yyyy", - ) - : "your selected date"} - -

-
@@ -1256,29 +1211,13 @@ export default function ResultsPage() { {inboundSchedules.length === 0 && alternativeInbound.length > 0 && (
-
-
- +
+
+ + No trains on {requestedReturnDate ? format(new Date(`${requestedReturnDate}T00:00:00`), "EEEE, MMMM d") : "your selected return date"}.
-

- No trains available -

-

- No trains are available on{" "} - - {requestedReturnDate - ? format( - new Date(`${requestedReturnDate}T00:00:00`), - "EEEE, MMMM d, yyyy", - ) - : "your selected return date"} - -

-