mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
Dashboard, seats report, package pricing updates
This commit is contained in:
@@ -866,6 +866,15 @@ export class BookingsService {
|
|||||||
resolvedTotalMinor = displayCurrency !== Currency.ETB
|
resolvedTotalMinor = displayCurrency !== Currency.ETB
|
||||||
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||||
: dto.reviewedTotalMinor;
|
: 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) {
|
} else if (allFaresProvided) {
|
||||||
// seatFareMinor is in display currency — sum is already the display total
|
// seatFareMinor is in display currency — sum is already the display total
|
||||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
|
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
|
||||||
@@ -1050,6 +1059,19 @@ export class BookingsService {
|
|||||||
totalMinor = displayCurrency !== Currency.ETB
|
totalMinor = displayCurrency !== Currency.ETB
|
||||||
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
|
||||||
: dto.reviewedTotalMinor;
|
: 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) {
|
} else if (allRTFaresProvided && !dto.packageId) {
|
||||||
// seatFareMinor/returnSeatFareMinor are in display currency — sum is already the display total
|
// seatFareMinor/returnSeatFareMinor are in display currency — sum is already the display total
|
||||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
||||||
|
|||||||
@@ -231,6 +231,14 @@ export class GuestBookingService {
|
|||||||
let resolvedTotalMinor: number;
|
let resolvedTotalMinor: number;
|
||||||
if (dto.reviewedTotalMinor != null) {
|
if (dto.reviewedTotalMinor != null) {
|
||||||
displayTotalMinor = dto.reviewedTotalMinor;
|
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) {
|
} else if (allFaresProvided) {
|
||||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
|
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
|
||||||
} else {
|
} else {
|
||||||
@@ -515,6 +523,17 @@ export class GuestBookingService {
|
|||||||
totalMinor = displayCurrency !== Currency.ETB
|
totalMinor = displayCurrency !== Currency.ETB
|
||||||
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
|
||||||
: displayTotalMinor;
|
: 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) {
|
} else if (allRTFaresProvided && !isPackageRoundTrip) {
|
||||||
// seatFareMinor/returnSeatFareMinor are display-currency — sum is already display total
|
// seatFareMinor/returnSeatFareMinor are display-currency — sum is already display total
|
||||||
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { Controller, Get, Param, SetMetadata, UseGuards } from '@nestjs/common';
|
|||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { DashboardService } from './dashboard.service';
|
import { DashboardService } from './dashboard.service';
|
||||||
import { JwtGuard } from '../../common/jwt.guard';
|
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')
|
@ApiTags('Dashboard')
|
||||||
@Controller('dashboard')
|
@Controller('dashboard')
|
||||||
@@ -10,7 +11,7 @@ export class DashboardController {
|
|||||||
constructor(private service: DashboardService) {}
|
constructor(private service: DashboardService) {}
|
||||||
|
|
||||||
@Get('backoffice-stats')
|
@Get('backoffice-stats')
|
||||||
@PassengerAdmin()
|
@PassengerStaff([PASSENGER_PERMS.dashboard.view, PASSENGER_PERMS.admin])
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({ summary: 'Backoffice summary: totals and revenue by currency' })
|
@ApiOperation({ summary: 'Backoffice summary: totals and revenue by currency' })
|
||||||
getBackofficeStats() { return this.service.getBackofficeStats(); }
|
getBackofficeStats() { return this.service.getBackofficeStats(); }
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ export class DashboardService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getBackofficeStats() {
|
async getBackofficeStats() {
|
||||||
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, revenueRows, packageRevenueRows] =
|
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.prisma.booking.count(),
|
this.prisma.booking.count(),
|
||||||
this.prisma.booking.count({ where: { packageId: { not: null } } }),
|
this.prisma.booking.count({ where: { packageId: { not: null } } }),
|
||||||
this.prisma.ticket.count(),
|
this.prisma.ticket.count(),
|
||||||
this.prisma.passenger.count(),
|
this.prisma.passenger.count(),
|
||||||
|
this.prisma.seat.count({ where: { status: 'BLOCKED' } }),
|
||||||
this.prisma.$queryRaw<{ currency: string; total: bigint }[]>`
|
this.prisma.$queryRaw<{ currency: string; total: bigint }[]>`
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE("displayCurrency"::text, "currency"::text) AS currency,
|
COALESCE("displayCurrency"::text, "currency"::text) AS currency,
|
||||||
@@ -56,6 +57,7 @@ export class DashboardService {
|
|||||||
totalPackageTickets,
|
totalPackageTickets,
|
||||||
totalNormalTickets: totalTickets - totalPackageTickets,
|
totalNormalTickets: totalTickets - totalPackageTickets,
|
||||||
totalPassengers,
|
totalPassengers,
|
||||||
|
blockedSeatsCount,
|
||||||
revenueByCurrency: toMap(revenueRows),
|
revenueByCurrency: toMap(revenueRows),
|
||||||
packageRevenueByCurrency: toMap(packageRevenueRows),
|
packageRevenueByCurrency: toMap(packageRevenueRows),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ function DashboardPageContent() {
|
|||||||
queryKey: ['backoffice-stats'],
|
queryKey: ['backoffice-stats'],
|
||||||
queryFn: dashboardApi.getBackofficeStats,
|
queryFn: dashboardApi.getBackofficeStats,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
staleTime: 60000,
|
staleTime: 30000,
|
||||||
|
refetchInterval: 60000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: paymentMethods } = useQuery({
|
const { data: paymentMethods } = useQuery({
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 { bookingsApi } from '@/lib/api';
|
||||||
|
import { dashboardApi } from '@/lib/api/dashboard';
|
||||||
import Badge from '@/components/ui/Badge';
|
import Badge from '@/components/ui/Badge';
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||||
@@ -46,6 +47,12 @@ export default function SeatStatusReportPage() {
|
|||||||
const [statusFilter, setStatusFilter] = useState<'ALL' | 'PAID' | 'UNPAID'>('ALL');
|
const [statusFilter, setStatusFilter] = useState<'ALL' | 'PAID' | 'UNPAID'>('ALL');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const { data: stats } = useQuery({
|
||||||
|
queryKey: ['backoffice-stats'],
|
||||||
|
queryFn: dashboardApi.getBackofficeStats,
|
||||||
|
staleTime: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: bookingsData, isLoading } = useQuery({
|
const { data: bookingsData, isLoading } = useQuery({
|
||||||
queryKey: ['seat-report-bookings'],
|
queryKey: ['seat-report-bookings'],
|
||||||
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
|
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
|
||||||
@@ -150,7 +157,7 @@ export default function SeatStatusReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -189,6 +196,19 @@ export default function SeatStatusReportPage() {
|
|||||||
<AlertCircle className="h-8 w-8 text-red-500 opacity-30" />
|
<AlertCircle className="h-8 w-8 text-red-500 opacity-30" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground text-sm font-medium">Blocked Seats</p>
|
||||||
|
<p className="text-2xl font-bold mt-2 text-slate-600 dark:text-slate-400">
|
||||||
|
{stats?.blockedSeatsCount ?? '—'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Globally blocked</p>
|
||||||
|
</div>
|
||||||
|
<Ban className="h-8 w-8 text-slate-500 opacity-30" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Bell, LogOut, Moon, Sun, ChevronDown, HelpCircle, KeyRound } from 'lucide-react';
|
import { Bell, LogOut, Moon, Sun, ChevronDown, HelpCircle, KeyRound, ScanLine } from 'lucide-react';
|
||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
import { useTheme } from '@/lib/theme-store';
|
import { useTheme } from '@/lib/theme-store';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -18,6 +18,13 @@ export default function Header() {
|
|||||||
<header className="flex h-16 items-center justify-between border-b border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-6 shadow-sm">
|
<header className="flex h-16 items-center justify-between border-b border-gray-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-6 shadow-sm">
|
||||||
<div className="flex flex-1 items-center gap-4">
|
<div className="flex flex-1 items-center gap-4">
|
||||||
<h2 className="text-xl font-semibold text-[rgb(20,113,76)] dark:text-[rgb(20,113,76)]"></h2>
|
<h2 className="text-xl font-semibold text-[rgb(20,113,76)] dark:text-[rgb(20,113,76)]"></h2>
|
||||||
|
<Link
|
||||||
|
href="/boarding"
|
||||||
|
className="ml-auto rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-slate-800 transition-colors"
|
||||||
|
title="Boarding / Gate Scan"
|
||||||
|
>
|
||||||
|
<ScanLine className="h-5 w-5 text-[rgb(20,113,76)] dark:text-slate-400" />
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const dashboardApi = {
|
|||||||
totalNormalTickets: number;
|
totalNormalTickets: number;
|
||||||
totalPackageTickets: number;
|
totalPackageTickets: number;
|
||||||
totalPassengers: number;
|
totalPassengers: number;
|
||||||
|
blockedSeatsCount: number;
|
||||||
revenueByCurrency: { currency: string; totalMinor: number }[];
|
revenueByCurrency: { currency: string; totalMinor: number }[];
|
||||||
packageRevenueByCurrency: { currency: string; totalMinor: number }[];
|
packageRevenueByCurrency: { currency: string; totalMinor: number }[];
|
||||||
}>('/dashboard/backoffice-stats');
|
}>('/dashboard/backoffice-stats');
|
||||||
|
|||||||
Reference in New Issue
Block a user