mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 07:38:10 +00:00
Merge branch 'quick-fix-main' of github.com:Tria-plc/edr-platform into quick-fix-main
This commit is contained in:
@@ -13,8 +13,8 @@ export class DashboardService {
|
|||||||
async getBackofficeStats() {
|
async getBackofficeStats() {
|
||||||
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, revenueRows, packageRevenueRows] =
|
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, revenueRows, packageRevenueRows] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.prisma.booking.count(),
|
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] } } }),
|
||||||
this.prisma.booking.count({ where: { packageId: { not: null } } }),
|
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] }, packageId: { not: null } } }),
|
||||||
this.prisma.ticket.count(),
|
this.prisma.ticket.count(),
|
||||||
this.prisma.passenger.count(),
|
this.prisma.passenger.count(),
|
||||||
this.prisma.$queryRaw<{ currency: string; total: bigint }[]>`
|
this.prisma.$queryRaw<{ currency: string; total: bigint }[]>`
|
||||||
|
|||||||
@@ -95,13 +95,18 @@ export class ReportsService {
|
|||||||
where: { departureAt: { gte: dateFrom, lte: dateTo } },
|
where: { departureAt: { gte: dateFrom, lte: dateTo } },
|
||||||
include: {
|
include: {
|
||||||
coachAssignments: { include: { coach: { include: { seats: true } } } },
|
coachAssignments: { include: { coach: { include: { seats: true } } } },
|
||||||
bookings: { include: { seats: true } },
|
bookings: {
|
||||||
|
where: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||||
|
include: { seats: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const tripData = schedules.map(schedule => {
|
const tripData = schedules.map(schedule => {
|
||||||
const totalSeats = schedule.coachAssignments.reduce((sum, a) => sum + a.coach.seats.length, 0);
|
const totalSeats = schedule.coachAssignments.reduce((sum, a) => sum + a.coach.seats.length, 0);
|
||||||
const bookedSeats = schedule.bookings.reduce((sum, b) => sum + b.seats.length, 0);
|
const bookedSeats = schedule.bookings.reduce(
|
||||||
|
(sum, b) => sum + b.seats.filter((s: any) => s.leg === 1).length, 0,
|
||||||
|
);
|
||||||
const occupancyRate = totalSeats > 0 ? (bookedSeats / totalSeats) * 100 : 0;
|
const occupancyRate = totalSeats > 0 ? (bookedSeats / totalSeats) * 100 : 0;
|
||||||
return { scheduleId: schedule.id, departureAt: schedule.departureAt, totalSeats, bookedSeats, occupancyRate: +occupancyRate.toFixed(2) };
|
return { scheduleId: schedule.id, departureAt: schedule.departureAt, totalSeats, bookedSeats, occupancyRate: +occupancyRate.toFixed(2) };
|
||||||
});
|
});
|
||||||
@@ -212,6 +217,7 @@ export class ReportsService {
|
|||||||
where: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
where: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||||
include: {
|
include: {
|
||||||
seats: {
|
seats: {
|
||||||
|
where: { leg: 1 },
|
||||||
include: {
|
include: {
|
||||||
seat: { include: { coach: { include: { coachType: true } } } },
|
seat: { include: { coach: { include: { coachType: true } } } },
|
||||||
},
|
},
|
||||||
@@ -307,8 +313,8 @@ export class ReportsService {
|
|||||||
async getPassengerList(scheduleId: string) {
|
async getPassengerList(scheduleId: string) {
|
||||||
const seats = await this.prisma.bookingSeat.findMany({
|
const seats = await this.prisma.bookingSeat.findMany({
|
||||||
where: {
|
where: {
|
||||||
scheduleId,
|
leg: 1,
|
||||||
booking: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
booking: {
|
booking: {
|
||||||
@@ -324,20 +330,25 @@ export class ReportsService {
|
|||||||
},
|
},
|
||||||
seat: { include: { coach: { select: { number: true, coachType: { select: { name: true } } } } } },
|
seat: { include: { coach: { select: { number: true, coachType: { select: { name: true } } } } } },
|
||||||
},
|
},
|
||||||
orderBy: [{ seat: { coach: { number: 'asc' } } }],
|
orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Resolve station names in one query
|
||||||
const stationIds = [...new Set(
|
const stationIds = [...new Set(
|
||||||
seats.flatMap(bs => [bs.booking.originStationId, bs.booking.destinationStationId]).filter(Boolean) as string[]
|
seats.flatMap(bs => [bs.booking.originStationId, bs.booking.destinationStationId]).filter(Boolean) as string[],
|
||||||
)];
|
)];
|
||||||
const stations = stationIds.length
|
const stations = stationIds.length > 0
|
||||||
? await this.prisma.station.findMany({ where: { id: { in: stationIds } }, select: { id: true, name: true } })
|
? await this.prisma.station.findMany({ where: { id: { in: stationIds } }, select: { id: true, name: true } })
|
||||||
: [];
|
: [];
|
||||||
const stationMap = new Map(stations.map(s => [s.id, s.name]));
|
const stationName = new Map(stations.map(s => [s.id, s.name]));
|
||||||
|
|
||||||
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||||
|
where: { id: scheduleId },
|
||||||
|
select: { departureAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
return seats.map(bs => ({
|
return seats.map(bs => ({
|
||||||
bookingRef: bs.booking.bookingRef,
|
bookingRef: bs.booking.bookingRef,
|
||||||
bookingStatus: bs.booking.status,
|
|
||||||
passengerName: bs.passengerName,
|
passengerName: bs.passengerName,
|
||||||
passengerCategory: bs.passengerCategory,
|
passengerCategory: bs.passengerCategory,
|
||||||
idDocumentType: bs.idDocumentType,
|
idDocumentType: bs.idDocumentType,
|
||||||
@@ -347,8 +358,8 @@ export class ReportsService {
|
|||||||
seatLabel: bs.seatLabelSnapshot,
|
seatLabel: bs.seatLabelSnapshot,
|
||||||
coachNumber: bs.seat?.coach?.number ?? null,
|
coachNumber: bs.seat?.coach?.number ?? null,
|
||||||
coachType: (bs.seat?.coach as any)?.coachType?.name ?? null,
|
coachType: (bs.seat?.coach as any)?.coachType?.name ?? null,
|
||||||
origin: bs.booking.originStationId ? (stationMap.get(bs.booking.originStationId) ?? null) : null,
|
origin: bs.booking.originStationId ? (stationName.get(bs.booking.originStationId) ?? null) : null,
|
||||||
destination: bs.booking.destinationStationId ? (stationMap.get(bs.booking.destinationStationId) ?? null) : null,
|
destination: bs.booking.destinationStationId ? (stationName.get(bs.booking.destinationStationId) ?? null) : null,
|
||||||
amountPaidMinor: bs.booking.totalMinor,
|
amountPaidMinor: bs.booking.totalMinor,
|
||||||
currency: bs.booking.currency ?? 'ETB',
|
currency: bs.booking.currency ?? 'ETB',
|
||||||
isGroupBooking: (bs.booking._count?.seats ?? 0) > 1,
|
isGroupBooking: (bs.booking._count?.seats ?? 0) > 1,
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
|||||||
export class SeatsController {
|
export class SeatsController {
|
||||||
constructor(private service: SeatsService) {}
|
constructor(private service: SeatsService) {}
|
||||||
|
|
||||||
|
// ── Blocked Seats ─────────────────────────────────────────────────────────
|
||||||
|
@Get('blocks')
|
||||||
|
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
||||||
|
@ApiBearerAuth('IAM-auth')
|
||||||
|
@ApiOperation({ summary: 'List all blocked seats with reason and coach info' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Blocked seat records' })
|
||||||
|
getBlockedSeats() {
|
||||||
|
return this.service.getBlockedSeats();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Coach Availability ────────────────────────────────────────────────────
|
// ── Coach Availability ────────────────────────────────────────────────────
|
||||||
@Get('coaches/:scheduleId')
|
@Get('coaches/:scheduleId')
|
||||||
@SetMetadata('isPublic', true)
|
@SetMetadata('isPublic', true)
|
||||||
|
|||||||
@@ -598,6 +598,29 @@ export class SeatsService {
|
|||||||
await this.prisma.journey.deleteMany({ where: { bookingId } as any });
|
await this.prisma.journey.deleteMany({ where: { bookingId } as any });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getBlockedSeats() {
|
||||||
|
const blocks = await this.prisma.seatBlock.findMany({
|
||||||
|
where: {
|
||||||
|
NOT: { reason: { startsWith: 'MAINTENANCE:' } },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
seat: { include: { coach: { select: { number: true } } } },
|
||||||
|
},
|
||||||
|
orderBy: { blockedAt: 'desc' },
|
||||||
|
});
|
||||||
|
return blocks.map(b => ({
|
||||||
|
id: b.id,
|
||||||
|
seatId: b.seatId,
|
||||||
|
seatNumber: b.seat.seatNumber,
|
||||||
|
coachNumber: b.seat.coach.number,
|
||||||
|
scheduleId: b.scheduleId,
|
||||||
|
reason: b.reason,
|
||||||
|
blockedBy: b.blockedBy,
|
||||||
|
blockedAt: b.blockedAt,
|
||||||
|
unblockAt: b.unblockAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async getCoachesWithAvailability(scheduleId: string, originStationId?: string, destinationStationId?: string) {
|
async getCoachesWithAvailability(scheduleId: string, originStationId?: string, destinationStationId?: string) {
|
||||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||||
where: { id: scheduleId },
|
where: { id: scheduleId },
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||||
import { PERMS } from '@/lib/permissions';
|
import { PERMS } from '@/lib/permissions';
|
||||||
import { Ticket, AlertCircle, BookOpen, Banknote, ArrowRight } from 'lucide-react';
|
import { Ticket, AlertCircle, BookOpen, Banknote, ArrowRight, ScanLine } from 'lucide-react';
|
||||||
import { dashboardApi } from '@/lib/api/dashboard';
|
import { dashboardApi } from '@/lib/api/dashboard';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { formatCurrency } from '@/lib/utils';
|
import { formatCurrency } from '@/lib/utils';
|
||||||
@@ -143,9 +143,18 @@ function DashboardPageContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
<div>
|
<div className="flex items-start justify-between">
|
||||||
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
|
<div>
|
||||||
<p className="text-muted-foreground mt-1">Welcome back! Here's your operational summary.</p>
|
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">Welcome back! Here's your operational summary.</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/boarding"
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<ScanLine className="h-4 w-4" />
|
||||||
|
Boarding
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{statsError && (
|
{statsError && (
|
||||||
@@ -161,7 +170,7 @@ function DashboardPageContent() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stat cards */}
|
{/* Stat cards */}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={<BookOpen className="h-4 w-4 text-blue-600 dark:text-blue-400" />}
|
icon={<BookOpen className="h-4 w-4 text-blue-600 dark:text-blue-400" />}
|
||||||
iconBg="bg-blue-100 dark:bg-blue-900/30"
|
iconBg="bg-blue-100 dark:bg-blue-900/30"
|
||||||
@@ -174,18 +183,7 @@ function DashboardPageContent() {
|
|||||||
{ label: 'Package', value: stats?.totalPackageBookings ?? 0, href: '/package-bookings' },
|
{ label: 'Package', value: stats?.totalPackageBookings ?? 0, href: '/package-bookings' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
{/* Tickets card hidden temporarily */}
|
||||||
icon={<Ticket className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />}
|
|
||||||
iconBg="bg-emerald-100 dark:bg-emerald-900/30"
|
|
||||||
label="Tickets"
|
|
||||||
total={stats?.totalTickets ?? 0}
|
|
||||||
loading={statsLoading}
|
|
||||||
href="/tickets"
|
|
||||||
rows={[
|
|
||||||
{ label: 'Regular', value: stats?.totalNormalTickets ?? 0, href: '/tickets' },
|
|
||||||
{ label: 'Package', value: stats?.totalPackageTickets ?? 0, href: '/tickets' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Revenue card */}
|
{/* Revenue card */}
|
||||||
<div className="card flex flex-col gap-3">
|
<div className="card flex flex-col gap-3">
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ interface PassengersReport {
|
|||||||
|
|
||||||
interface PassengerRow {
|
interface PassengerRow {
|
||||||
bookingRef: string;
|
bookingRef: string;
|
||||||
bookingStatus: string;
|
|
||||||
passengerName: string;
|
passengerName: string;
|
||||||
passengerCategory: string;
|
passengerCategory: string;
|
||||||
idDocumentType: string | null;
|
idDocumentType: string | null;
|
||||||
@@ -139,9 +138,6 @@ export default function PassengersReportPage() {
|
|||||||
{data && tab === 'occupancy' && (
|
{data && tab === 'occupancy' && (
|
||||||
<ActionButton icon={Download} variant="secondary" onClick={doExportOccupancy}>Export CSV</ActionButton>
|
<ActionButton icon={Download} variant="secondary" onClick={doExportOccupancy}>Export CSV</ActionButton>
|
||||||
)}
|
)}
|
||||||
{passengerList.length > 0 && tab === 'list' && (
|
|
||||||
<ActionButton icon={Download} variant="secondary" onClick={doExportList}>Export CSV</ActionButton>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{(isLoading || listLoading) && <p className="text-xs text-muted-foreground mt-2">Loading…</p>}
|
{(isLoading || listLoading) && <p className="text-xs text-muted-foreground mt-2">Loading…</p>}
|
||||||
{isError && <p className="text-xs text-red-500 mt-2">Failed to load report.</p>}
|
{isError && <p className="text-xs text-red-500 mt-2">Failed to load report.</p>}
|
||||||
@@ -288,33 +284,19 @@ export default function PassengersReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Passenger List tab */}
|
|
||||||
{tab === 'list' && (
|
{tab === 'list' && (
|
||||||
<div className="card space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="input min-w-52 flex-1"
|
className="input max-w-sm flex-1"
|
||||||
placeholder="Search by name, booking ref or ID…"
|
placeholder="Search by name or booking ref…"
|
||||||
value={listSearch}
|
value={listSearch}
|
||||||
onChange={e => setListSearch(e.target.value)}
|
onChange={e => setListSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<select
|
{passengerList.length > 0 && (
|
||||||
className="input w-44"
|
<ActionButton icon={Download} variant="secondary" onClick={doExportList}>Export CSV</ActionButton>
|
||||||
value={filterCoach}
|
)}
|
||||||
onChange={e => setFilterCoach(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">All coaches</option>
|
|
||||||
{coachOptions.map(c => <option key={c} value={c}>Coach {c}</option>)}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
className="input w-52"
|
|
||||||
value={filterOrigin}
|
|
||||||
onChange={e => setFilterOrigin(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">All origins</option>
|
|
||||||
{originOptions.map(o => <option key={o} value={o}>{o}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
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, seatsApi } from '@/lib/api';
|
||||||
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 +46,11 @@ 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: blockedSeats = [] } = useQuery({
|
||||||
|
queryKey: ['blocked-seats'],
|
||||||
|
queryFn: () => seatsApi.getBlocked().then((r: any) => Array.isArray(r) ? r : r?.data ?? []),
|
||||||
|
});
|
||||||
|
|
||||||
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 +155,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-2 lg: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 +194,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">
|
||||||
|
{blockedSeats.length}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Manually blocked</p>
|
||||||
|
</div>
|
||||||
|
<Ban className="h-8 w-8 text-slate-500 opacity-30" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export const seatsApi = {
|
|||||||
return apiClient.get<any>(`/seats/seatmap/${scheduleId}${params}`);
|
return apiClient.get<any>(`/seats/seatmap/${scheduleId}${params}`);
|
||||||
},
|
},
|
||||||
getBySchedule: (scheduleId: string) => apiClient.get<any>(`/seats/schedule/${scheduleId}`),
|
getBySchedule: (scheduleId: string) => apiClient.get<any>(`/seats/schedule/${scheduleId}`),
|
||||||
|
getBlocked: () => apiClient.get<any[]>('/seats/blocks'),
|
||||||
hold: (data: any) => apiClient.post<any>('/seats/hold', data),
|
hold: (data: any) => apiClient.post<any>('/seats/hold', data),
|
||||||
release: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`),
|
release: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`),
|
||||||
block: (seatId: string, data: any) => apiClient.post<any>(`/seats/${seatId}/block`, data),
|
block: (seatId: string, data: any) => apiClient.post<any>(`/seats/${seatId}/block`, data),
|
||||||
|
|||||||
Reference in New Issue
Block a user