diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 29a262722..a0994d1be 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -866,6 +866,15 @@ export class BookingsService { resolvedTotalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB) : dto.reviewedTotalMinor; + // For package bookings where per-seat fares weren't supplied, back-derive the + // per-seat fare from reviewedTotalMinor so BookingSeat.fareMinor reflects the + // actual berth price (Upper/Middle/Lower) rather than the tier's minimum price. + if (dto.packageId && seatedPassengers.length > 0) { + const perSeatFare = Math.round(dto.reviewedTotalMinor / seatedPassengers.length); + passengersWithFares.forEach(p => { + if (p.fareMinor > 0) p.fareMinor = p.seatFareMinor ?? perSeatFare; + }); + } } else if (allFaresProvided) { // seatFareMinor is in display currency — sum is already the display total displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0); @@ -1050,6 +1059,19 @@ export class BookingsService { totalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB) : dto.reviewedTotalMinor; + // For package bookings where per-seat fares weren't supplied, back-derive the + // per-leg per-seat fare from reviewedTotalMinor so BookingSeat.fareMinor reflects + // the actual berth price (Upper/Middle/Lower) rather than the tier's minimum price. + if (dto.packageId) { + const seatedCount = passengersData.filter(p => p.outboundSeatId).length; + if (seatedCount > 0) { + const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2)); + passengersWithFares.forEach(p => { + if (p.outboundFareMinor > 0) p.outboundFareMinor = p.seatFareMinor ?? perSeatPerLeg; + if (p.returnFareMinor > 0) p.returnFareMinor = p.returnSeatFareMinor ?? perSeatPerLeg; + }); + } + } } else if (allRTFaresProvided && !dto.packageId) { // seatFareMinor/returnSeatFareMinor are in display currency — sum is already the display total displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0); diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 821db5177..43470c160 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -231,6 +231,14 @@ export class GuestBookingService { let resolvedTotalMinor: number; if (dto.reviewedTotalMinor != null) { displayTotalMinor = dto.reviewedTotalMinor; + // For package bookings, back-derive per-seat fareMinor from reviewedTotalMinor + // so BookingSeat records store the actual berth price, not the tier minimum. + if (isPackageOneway && seatedPassengers.length > 0) { + const perSeatFare = Math.round(dto.reviewedTotalMinor / seatedPassengers.length); + passengersWithFares.forEach(p => { + if (p.fareMinor > 0) p.fareMinor = p.seatFareMinor ?? perSeatFare; + }); + } } else if (allFaresProvided) { displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0); } else { @@ -515,6 +523,17 @@ export class GuestBookingService { totalMinor = displayCurrency !== Currency.ETB ? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB) : displayTotalMinor; + // For package bookings, back-derive per-leg per-seat fareMinor from reviewedTotalMinor. + if (isPackageRoundTrip) { + const seatedCount = passengersData.filter(p => p.seatId).length; + if (seatedCount > 0) { + const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2)); + passengersWithFares.forEach(p => { + if (p.outboundFareMinor > 0) p.outboundFareMinor = p.seatFareMinor ?? perSeatPerLeg; + if (p.returnFareMinor > 0) p.returnFareMinor = p.returnSeatFareMinor ?? perSeatPerLeg; + }); + } + } } else if (allRTFaresProvided && !isPackageRoundTrip) { // seatFareMinor/returnSeatFareMinor are display-currency — sum is already display total displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0); diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts index 776781ddd..eb7bd33b6 100644 --- a/apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.controller.ts @@ -2,7 +2,8 @@ import { Controller, Get, Param, SetMetadata, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { DashboardService } from './dashboard.service'; import { JwtGuard } from '../../common/jwt.guard'; -import { PassengerAdmin } from '../../common/passenger-guards'; +import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @ApiTags('Dashboard') @Controller('dashboard') @@ -10,7 +11,7 @@ export class DashboardController { constructor(private service: DashboardService) {} @Get('backoffice-stats') - @PassengerAdmin() + @PassengerStaff([PASSENGER_PERMS.dashboard.view, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Backoffice summary: totals and revenue by currency' }) getBackofficeStats() { return this.service.getBackofficeStats(); } diff --git a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts index aed950b73..62a1a6461 100644 --- a/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts +++ b/apps/edr-passenger-api/src/modules/dashboard/dashboard.service.ts @@ -11,12 +11,13 @@ export class DashboardService { ) {} async getBackofficeStats() { - const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, revenueRows, packageRevenueRows] = + const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows] = await Promise.all([ this.prisma.booking.count(), this.prisma.booking.count({ where: { packageId: { not: null } } }), this.prisma.ticket.count(), this.prisma.passenger.count(), + this.prisma.seat.count({ where: { status: 'BLOCKED' } }), this.prisma.$queryRaw<{ currency: string; total: bigint }[]>` SELECT COALESCE("displayCurrency"::text, "currency"::text) AS currency, @@ -56,6 +57,7 @@ export class DashboardService { totalPackageTickets, totalNormalTickets: totalTickets - totalPackageTickets, totalPassengers, + blockedSeatsCount, revenueByCurrency: toMap(revenueRows), packageRevenueByCurrency: toMap(packageRevenueRows), }; diff --git a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx index bc657106d..09f1bdaf7 100644 --- a/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/dashboard/page.tsx @@ -99,7 +99,8 @@ function DashboardPageContent() { queryKey: ['backoffice-stats'], queryFn: dashboardApi.getBackofficeStats, retry: 1, - staleTime: 60000, + staleTime: 30000, + refetchInterval: 60000, }); const { data: paymentMethods } = useQuery({ diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx index ea2d65cf0..08b4f2462 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx @@ -2,8 +2,9 @@ import { useState, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Download, Armchair, CheckCircle, Clock, AlertCircle } from 'lucide-react'; +import { Download, Armchair, CheckCircle, Clock, AlertCircle, Ban } from 'lucide-react'; import { bookingsApi } from '@/lib/api'; +import { dashboardApi } from '@/lib/api/dashboard'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; import { formatDateTime, formatCurrency } from '@/lib/utils'; @@ -46,6 +47,12 @@ export default function SeatStatusReportPage() { const [statusFilter, setStatusFilter] = useState<'ALL' | 'PAID' | 'UNPAID'>('ALL'); const [search, setSearch] = useState(''); + const { data: stats } = useQuery({ + queryKey: ['backoffice-stats'], + queryFn: dashboardApi.getBackofficeStats, + staleTime: 30000, + }); + const { data: bookingsData, isLoading } = useQuery({ queryKey: ['seat-report-bookings'], queryFn: () => bookingsApi.getAll({ pageSize: 1000 }), @@ -150,7 +157,7 @@ export default function SeatStatusReportPage() { {/* Summary Cards */} -
Blocked Seats
++ {stats?.blockedSeatsCount ?? '—'} +
+Globally blocked
+