diff --git a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx index cff41ede2..e2c0fefb5 100644 --- a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx @@ -140,7 +140,7 @@ export default function PassengersPage() { ), }, - { key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone || 'N/A' }, + { key: 'phone', label: 'Phone', sortable: true, render: (p: any) => p.phone || p.passenger?.user?.phone || '—' }, { key: 'nationality', label: 'Nationality', sortable: true, render: (p: any) => p.nationality || 'N/A' }, { key: 'gender', label: 'Gender', sortable: true, render: (p: any) => p.gender || 'N/A' }, { key: 'dateOfBirth', label: 'Date of Birth', sortable: true, render: (p: any) => p.dateOfBirth ? formatDate(p.dateOfBirth) : 'N/A' }, 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 33f39d87e..ad10052fd 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 @@ -166,7 +166,10 @@ export default function TariffRatesPage() { }, { key: 'coachType', label: 'Coach Type', - render: (c: any) => {c.coachType?.name || c.coachTypeId}, + render: (c: any) => { + const ct = coachTypesArray.find((t: any) => t.id === c.coachTypeId); + return {ct ? `${ct.code} — ${ct.name}` : c.coachTypeId}; + }, }, { key: 'bedPosition', label: 'Bed Position', diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index e0c7122e3..c737da4de 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -343,15 +343,12 @@ export default function TicketsPage() { key: 'contact', label: 'Contact', render: (ticket: any) => { - const phone = ticket.booking?.passenger?.phone || 'N/A'; - const email = ticket.booking?.passenger?.email || 'N/A'; - + const phone = ticket.booking?.contactPhone || ticket.booking?.passenger?.phone || '—'; + const email = ticket.booking?.contactEmail || ticket.booking?.passenger?.email || '—'; return (
{phone}
-
-
{email}
-
+
{email}
); }, diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/passengers.ts b/apps/edr-passenger-web/backoffice/src/lib/api/passengers.ts index f7191e83b..f94e3d05f 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/passengers.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/passengers.ts @@ -10,7 +10,10 @@ export const passengersApi = { if (filters?.role) params.append('role', filters.role); if (filters?.page) params.append('page', filters.page.toString()); if (filters?.pageSize) params.append('pageSize', filters.pageSize.toString()); - + if ((filters as any)?.gender) params.append('gender', (filters as any).gender); + if ((filters as any)?.nationality) params.append('nationality', (filters as any).nationality); + if ((filters as any)?.dateFrom) params.append('dateFrom', (filters as any).dateFrom); + if ((filters as any)?.dateTo) params.append('dateTo', (filters as any).dateTo); return apiClient.get>(`/passengers?${params.toString()}`); }, @@ -21,4 +24,8 @@ export const passengersApi = { update: (id: string, data: Partial) => { return apiClient.patch(`/passengers/${id}`, data); }, + + delete: (id: string, cascade = false) => { + return apiClient.delete(`/passengers/${id}${cascade ? '?cascade=true' : ''}`); + }, }; diff --git a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx index 03b342d04..6ce8b5f66 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/payment/page.tsx @@ -72,10 +72,10 @@ export default function PaymentPage() { // split equally across both legs. This guarantees leg totals are consistent with the // per-passenger breakdown rows and the overall reviewed total. const outboundBaseFare = isRoundTrip - ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0) + ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : (f.outboundFareMinor ?? Math.round(f.fareMinor / 2))), 0) : 0; const inboundBaseFare = isRoundTrip - ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : Math.round(f.fareMinor / 2)), 0) + ? (reviewedPassengerFares ?? []).reduce((sum, f) => sum + (f.isFree ? 0 : (f.inboundFareMinor ?? Math.round(f.fareMinor / 2))), 0) : 0; // reviewedPassengerFares / reviewedTotalMinor are the single source of truth for display @@ -307,11 +307,11 @@ export default function PaymentPage() {
Outbound - {formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)} + {formatFare(reviewed?.outboundFareMinor ?? Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}
Return - {formatFare(Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)} + {formatFare(reviewed?.inboundFareMinor ?? Math.round((reviewed?.fareMinor ?? 0) / 2), displayCurrency)}
)} 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 b049c7b55..fb44dce41 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 @@ -293,7 +293,17 @@ export default function ResultsPage() { // For round trip inbound, proceed with both schedules if (isRoundTrip && !isOutbound) { - setInboundSchedule(scheduleData); + // Mirror the outbound's coachTypes (fares) onto the inbound schedule so the + // return seat selection page shows the same prices as the outbound leg. + const inboundScheduleData = outboundScheduleData + ? { + ...scheduleData, + baseFareAdult: outboundScheduleData.baseFareAdult, + baseFareChild: outboundScheduleData.baseFareChild, + coachTypes: outboundScheduleData.coachTypes, + } + : scheduleData; + setInboundSchedule(inboundScheduleData); setSelectedSchedule(outboundScheduleData); // Set primary as outbound } else { // For one-way 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 081626642..251f8ae8b 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 @@ -445,7 +445,9 @@ export default function ReviewPage() { const fareMinor = isPackageBooking ? (isFreeChild ? 0 : (seatFare ?? pkgFallback)) : (isFreeChild ? 0 : (seatFare ?? line?.fareMinor ?? 0)); - return { fareMinor, isFree: isFreeChild }; + const outboundFareMinor = isRoundTrip ? ((p as any).outboundSeatFareMinor ?? undefined) : undefined; + const inboundFareMinor = isRoundTrip ? ((p as any).inboundSeatFareMinor ?? undefined) : undefined; + return { fareMinor, isFree: isFreeChild, outboundFareMinor, inboundFareMinor }; }); setReviewedTotal(computedTotal, passengerFares); @@ -560,6 +562,14 @@ export default function ReviewPage() { const isFreeChild = isPackageBooking ? isPkgFreeChild(i) : (line?.isFree ?? (isChild(p) && isFirstChild(passengers, i))); + + // Per-leg fares for round trips + const outboundFare: number | null = isRoundTrip + ? (isPackageBooking ? (packageTierPriceMinor ?? null) : ((p as any).outboundSeatFareMinor ?? null)) + : null; + const inboundFare: number | null = isRoundTrip + ? (isPackageBooking ? (packageTierPriceMinor ?? null) : ((p as any).inboundSeatFareMinor ?? null)) + : null; const seatFare = getPassengerSeatFare(p); const passengerTotal = isPackageBooking ? (isFreeChild ? 0 : (seatFare ?? (isChildPassenger ? pkgChildFare : pkgAdultFare))) @@ -582,6 +592,19 @@ export default function ReviewPage() { {formatFare(passengerTotal, displayCurrency)} + {/* Round-trip: show outbound + inbound breakdown */} + {isRoundTrip && !isFreeChild && ( +
+
+ ↗ Outbound + {outboundFare != null ? formatFare(outboundFare, displayCurrency) : '—'} +
+
+ ↙ Return + {inboundFare != null ? formatFare(inboundFare, displayCurrency) : '—'} +
+
+ )} ); })} diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index 4b764e342..1463b238a 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -452,16 +452,15 @@ export default function SeatsPage() { // the new seat map data has finished loading. const applyCoachTypeSwitch = (coach: any, matchedType: any) => { const newFare = getCoachTypeFare(matchedType.coachTypeId || matchedType.coachId); - const firstClass = matchedType.classes?.[0]; const newCoachTypeId = matchedType.coachTypeId || matchedType.coachId; const updatedSchedule = { ...(currentSchedule as any), selectedCoachTypeId: newCoachTypeId, selectedCoachTypeCode: matchedType.coachTypeCode || coach.type || "", selectedCoachTypeName: matchedType.coachTypeName || coach.coachTypeName || coach.typeName || "", - selectedSeatClass: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "", - selectedSeatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "", - seatClassName: firstClass?.name || matchedType.coachTypeName || coach.coachTypeName || "", + selectedSeatClass: matchedType.coachTypeName || coach.coachTypeName || "", + selectedSeatClassName: matchedType.coachTypeName || coach.coachTypeName || "", + seatClassName: matchedType.coachTypeName || coach.coachTypeName || "", baseFareAdult: newFare ?? (currentSchedule as any)?.baseFareAdult, baseFareChild: newFare ?? (currentSchedule as any)?.baseFareChild, }; @@ -922,7 +921,7 @@ export default function SeatsPage() { const positionLabel = newSeat?.bedPosition ? `${newSeat.bedPosition.charAt(0).toUpperCase()}${newSeat.bedPosition.slice(1)} berth` : "This seat"; - const legMultiplier = isRoundTrip ? 2 : 1; + const legMultiplier = isPackageBooking && isRoundTrip ? 2 : 1; setModalState({ isOpen: true, @@ -1329,15 +1328,16 @@ export default function SeatsPage() { useEffect(() => { if (isRoundTrip) { if (!outboundSchedule || !inboundSchedule || !passengers.length) { - router.push("/booking/search"); + router.push(isPackageBooking ? "/booking/passengers" : "/booking/search"); } } else { if (!selectedSchedule || !passengers.length) { - router.push("/booking/search"); + router.push(isPackageBooking ? "/booking/passengers" : "/booking/search"); } } }, [ isRoundTrip, + isPackageBooking, selectedSchedule, outboundSchedule, inboundSchedule, @@ -1713,7 +1713,7 @@ export default function SeatsPage() { if ( isRoundTrip - ? !outboundSchedule || !inboundSchedule || !passengers.length + ? !outboundSchedule || (!isPackageBooking && !inboundSchedule) || !passengers.length : !selectedSchedule || !passengers.length ) return null; 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 32e3f1178..bb839906d 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 @@ -221,7 +221,7 @@ function groupTiersByCoachType(tiers: PriceTier[]): Array<{ if (!map.has(key)) { map.set(key, { coachTypeId: ct?.id ?? key, - coachTypeName: ct?.name ?? tier.seatType, + coachTypeName: ct?.name ?? tier.label ?? tier.seatType, coachTypeCode: ct?.code ?? '', coachTypeType: ct?.type ?? 'passenger', tiers: [], @@ -270,90 +270,121 @@ function PriceTiersPanel({ } return ( -
-

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} -

-
+
+

Choose Coach Type

+
+ {groups.map((group, index) => { + 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)} + onKeyDown={(e) => { + if (!allSoldOut && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + setSelectedId(isSelected ? null : group.coachTypeId); + } + }} + className={`group relative w-full p-2 rounded-2xl border-2 text-left transition-all duration-200 ${allSoldOut + ? 'border-gray-200 dark:border-gray-700 opacity-50 cursor-not-allowed' + : isSelected + ? 'border-primary bg-gradient-to-br from-primary/8 to-primary/3 dark:from-primary/15 dark:to-primary/5 shadow-lg shadow-primary/20 scale-[1.02] cursor-pointer' + : 'border-gray-200 dark:border-gray-700 shadow-sm hover:border-primary/40 hover:shadow-md hover:scale-[1.01] bg-white dark:bg-gray-800/50 cursor-pointer' + }`} + style={{ animation: `fade-in-up 0.3s ease-out ${index * 0.08}s both` }} + > + {/* Radio indicator */} {!allSoldOut && ( -
- {isSelected && } -
+ + {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 - )} -

+
+
+
+ +
+ +
+

+ {formatCoachTypeLabel(group.coachTypeType)} +

+ {allSoldOut && ( + Sold out + )} +
+ From + + {((group.minPrice * priceMultiplier) / 100).toFixed(2)} + + {group.currency}
- ); - })} -
+
- {/* Book Now — only when this group is selected */} - {isSelected && !allSoldOut && ( -
- + {/* Class options — always visible, matching results page style */} + {group.tiers.length > 0 && ( +
+
+ {group.tiers.map((tier) => { + const soldOut = tier.availableSeats === 0; + return ( +
+ {tier.seatType.trim()} + {soldOut ? ( + Sold out + ) : ( +
+ + {((tier.priceMinor * priceMultiplier) / 100).toFixed(2)} + + {tier.currency} +
+ )} +
+ ); + })} +
+
+ )} + + {!isSelected && !allSoldOut && ( +

+ Click to select this coach +

+ )} + + {isSelected && !allSoldOut && ( + + )}
- )} -
- ); - })} +
+ ); + })} +
+
); } @@ -407,7 +438,7 @@ function PassengerCountModal({

Coach type

-

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

+

{tier.seatClass?.coachType?.name ?? tier.label.trim()}

{remaining} seats remaining · from {formatPrice(minPriceMinor * priceMultiplier, tier.currency)} per adult · 1st child per adult free (no seat)

@@ -416,26 +447,26 @@ function PassengerCountModal({ { label: "Adults", sub: `Age 5+ · max ${PKG_MAX_ADULTS}`, value: adultCount, min: 1, max: Math.min(PKG_MAX_ADULTS, remaining), set: (v: number) => { setAdultCount(v); const newMax = Math.min(v * PKG_CHILDREN_PER_ADULT, v + Math.max(0, remaining - v)); setChildCount(c => Math.min(c, newMax)); } }, { label: "Children", sub: `Under 5 · max ${PKG_CHILDREN_PER_ADULT} per adult · 1st per adult FREE (no seat)`, value: childCount, min: 0, max: Math.min(adultCount * PKG_CHILDREN_PER_ADULT, adultCount + Math.max(0, remaining - adultCount)), set: setChildCount }, ].map(({ label, sub, value, min, max, set }) => ( -
-
-

{label}

-

{sub}

-
-
- - {value} - -
+
+
+

{label}

+

{sub}

- ))} +
+ + {value} + +
+
+ ))} {freeChildren > 0 && (
@@ -457,15 +488,14 @@ function PassengerCountModal({ {/* Departure Station */}
-