Merge branch 'main' into quick-fix-main

This commit is contained in:
Stephanos A.
2026-07-19 17:29:46 +03:00
committed by GitHub
8 changed files with 159 additions and 156 deletions

View File

@@ -13,8 +13,8 @@ export class DashboardService {
async getBackofficeStats() {
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, revenueRows, packageRevenueRows] =
await Promise.all([
this.prisma.booking.count(),
this.prisma.booking.count({ where: { packageId: { not: null } } }),
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] } } }),
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] }, packageId: { not: null } } }),
this.prisma.ticket.count(),
this.prisma.passenger.count(),
this.prisma.$queryRaw<{ currency: string; total: bigint }[]>`

View File

@@ -95,13 +95,18 @@ export class ReportsService {
where: { departureAt: { gte: dateFrom, lte: dateTo } },
include: {
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 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;
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'] } },
include: {
seats: {
where: { leg: 1 },
include: {
seat: { include: { coach: { include: { coachType: true } } } },
},
@@ -307,38 +313,46 @@ export class ReportsService {
async getPassengerList(scheduleId: string) {
const seats = await this.prisma.bookingSeat.findMany({
where: {
scheduleId,
booking: { status: { in: ['CONFIRMED', 'BOARDED'] } },
leg: 1,
booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED'] } },
},
include: {
booking: { select: { bookingRef: true, status: true, originStationId: true, destinationStationId: true } },
seat: { include: { coach: { select: { number: true, coachType: { select: { name: true } } } } } },
booking: {
select: {
bookingRef: true,
status: true,
originStationId: true,
destinationStationId: true,
},
},
seat: { include: { coach: { select: { number: true } } } },
},
orderBy: [{ seat: { coach: { number: 'asc' } } }],
orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }],
});
// Resolve station names in one query
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 } })
: [];
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 => ({
bookingRef: bs.booking.bookingRef,
bookingStatus: bs.booking.status,
passengerName: bs.passengerName,
passengerCategory: bs.passengerCategory,
idDocumentType: bs.idDocumentType,
idDocumentNumber: bs.idDocumentNumber,
passportNumber: bs.passportNumber,
passportCountry: bs.passportCountry,
seatLabel: bs.seatLabelSnapshot,
coachNumber: bs.seat?.coach?.number ?? null,
coachType: (bs.seat?.coach as any)?.coachType?.name ?? null,
origin: bs.booking.originStationId ? (stationMap.get(bs.booking.originStationId) ?? null) : null,
destination: bs.booking.destinationStationId ? (stationMap.get(bs.booking.destinationStationId) ?? null) : null,
coachSeat: bs.seat?.coach?.number && bs.seatLabelSnapshot
? `${bs.seat.coach.number}·${bs.seatLabelSnapshot}`
: (bs.seatLabelSnapshot ?? '—'),
origin: bs.booking.originStationId ? (stationName.get(bs.booking.originStationId) ?? '—') : '—',
destination: bs.booking.destinationStationId ? (stationName.get(bs.booking.destinationStationId) ?? '—') : '—',
departureAt: schedule?.departureAt ?? null,
}));
}

View File

@@ -29,6 +29,16 @@ import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
export class SeatsController {
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 ────────────────────────────────────────────────────
@Get('coaches/:scheduleId')
@SetMetadata('isPublic', true)

View File

@@ -598,6 +598,29 @@ export class SeatsService {
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) {
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: scheduleId },

View File

@@ -3,7 +3,7 @@
import { useQuery } from '@tanstack/react-query';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
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 { apiClient } from '@/lib/api-client';
import { formatCurrency } from '@/lib/utils';
@@ -143,9 +143,18 @@ function DashboardPageContent() {
return (
<div className="space-y-6 p-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
<p className="text-muted-foreground mt-1">Welcome back! Here&apos;s your operational summary.</p>
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
<p className="text-muted-foreground mt-1">Welcome back! Here&apos;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>
{statsError && (
@@ -161,7 +170,7 @@ function DashboardPageContent() {
)}
{/* 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
icon={<BookOpen className="h-4 w-4 text-blue-600 dark:text-blue-400" />}
iconBg="bg-blue-100 dark:bg-blue-900/30"
@@ -174,18 +183,7 @@ function DashboardPageContent() {
{ label: 'Package', value: stats?.totalPackageBookings ?? 0, href: '/package-bookings' },
]}
/>
<StatCard
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' },
]}
/>
{/* Tickets card hidden temporarily */}
{/* Revenue card */}
<div className="card flex flex-col gap-3">

View File

@@ -20,19 +20,11 @@ interface PassengersReport {
interface PassengerRow {
bookingRef: string;
bookingStatus: string;
passengerName: string;
passengerCategory: string;
idDocumentType: string | null;
idDocumentNumber: string | null;
passportNumber: string | null;
passportCountry: string | null;
nationality: string | null;
seatLabel: string | null;
coachNumber: string | null;
coachType: string | null;
origin: string | null;
destination: string | null;
coachSeat: string;
origin: string;
destination: string;
departureAt: string | null;
}
type Tab = 'occupancy' | 'list';
@@ -62,23 +54,12 @@ export default function PassengersReportPage() {
enabled: !!scheduleId,
});
const coachOptions = [...new Set(passengerList.map(p => p.coachNumber).filter(Boolean))].sort() as string[];
const originOptions = [...new Set(passengerList.map(p => p.origin).filter(Boolean))].sort() as string[];
const filteredList = passengerList.filter(p => {
if (filterCoach && p.coachNumber !== filterCoach) return false;
if (filterOrigin && p.origin !== filterOrigin) return false;
if (listSearch.trim()) {
const q = listSearch.toLowerCase();
return (
p.passengerName.toLowerCase().includes(q) ||
p.bookingRef.toLowerCase().includes(q) ||
(p.idDocumentNumber ?? '').toLowerCase().includes(q) ||
(p.passportNumber ?? '').toLowerCase().includes(q)
);
}
return true;
});
const filteredList = listSearch.trim()
? passengerList.filter(p =>
p.passengerName.toLowerCase().includes(listSearch.toLowerCase()) ||
p.bookingRef.toLowerCase().includes(listSearch.toLowerCase()),
)
: passengerList;
const downloadCsv = (csv: string, filename: string) => {
const blob = new Blob([csv], { type: 'text/csv' });
@@ -96,12 +77,10 @@ export default function PassengersReportPage() {
const doExportList = () => {
if (!passengerList.length) return;
const headers = ['Booking Ref', 'Status', 'Name', 'Category', 'Nationality / Passport', 'Coach · Seat', 'Origin', 'Destination'];
const rows = passengerList.map(p => [
p.bookingRef, p.bookingStatus, p.passengerName, p.passengerCategory,
p.passportNumber ? `${p.passportCountry ?? ''} · ${p.passportNumber}` : (p.idDocumentNumber ?? ''),
p.coachNumber && p.seatLabel ? `${p.coachNumber} · ${p.seatLabel}` : (p.coachNumber ?? p.seatLabel ?? ''),
p.origin ?? '', p.destination ?? '',
const headers = ['#', 'Name', 'Coach·Seat', 'Origin', 'Destination', 'Date', 'Booking Ref'];
const rows = passengerList.map((p, i) => [
String(i + 1), p.passengerName, p.coachSeat, p.origin, p.destination,
p.departureAt ? formatDateTime(p.departureAt) : '—', p.bookingRef,
].map(v => `"${String(v).replace(/"/g, '""')}"`));
downloadCsv([headers.join(','), ...rows.map(r => r.join(','))].join('\n'), `passengers-${scheduleId}.csv`);
};
@@ -133,9 +112,6 @@ export default function PassengersReportPage() {
{data && tab === 'occupancy' && (
<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>
{(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>}
@@ -282,89 +258,52 @@ export default function PassengersReportPage() {
</div>
)}
{/* Passenger List tab */}
{tab === 'list' && (
<div className="card space-y-4">
<div className="flex flex-wrap gap-3">
<div className="space-y-4">
<div className="flex items-center gap-3 flex-wrap">
<input
type="text"
className="input min-w-52 flex-1"
placeholder="Search by name, booking ref or ID…"
className="input max-w-sm flex-1"
placeholder="Search by name or booking ref…"
value={listSearch}
onChange={e => setListSearch(e.target.value)}
/>
<select
className="input w-44"
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>
{passengerList.length > 0 && (
<ActionButton icon={Download} variant="secondary" onClick={doExportList}>Export CSV</ActionButton>
)}
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
<th className="pb-2 pr-4">#</th>
<th className="pb-2 pr-4">Name</th>
<th className="pb-2 pr-4">Category</th>
<th className="pb-2 pr-4">Nationality / Passport</th>
<th className="pb-2 pr-4">Coach · Seat</th>
<th className="pb-2 pr-4">Origin</th>
<th className="pb-2 pr-4">Destination</th>
<th className="pb-2 pr-4">Booking Ref</th>
<th className="pb-2">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filteredList.map((p, i) => (
<tr key={`${p.bookingRef}-${i}`} className="hover:bg-muted/30">
<td className="py-2 pr-4 text-muted-foreground tabular-nums">{i + 1}</td>
<td className="py-2 pr-4 font-medium">{p.passengerName}</td>
<td className="py-2 pr-4">
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded ${p.passengerCategory === 'CHILD' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' : 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'}`}>
{p.passengerCategory}
</span>
</td>
<td className="py-2 pr-4 text-xs">
{p.passportNumber ? (
<>
<span className="text-muted-foreground">{p.passportCountry ?? 'Intl'}</span>
<span className="ml-1 font-mono">{p.passportNumber}</span>
</>
) : (
<span className="text-muted-foreground">{p.idDocumentNumber ?? '—'}</span>
)}
</td>
<td className="py-2 pr-4 font-mono text-xs">
{p.coachNumber && p.seatLabel
? `${p.coachNumber} · ${p.seatLabel}`
: (p.coachNumber ?? p.seatLabel ?? '—')}
</td>
<td className="py-2 pr-4 text-muted-foreground text-xs">{p.origin ?? '—'}</td>
<td className="py-2 pr-4 text-muted-foreground text-xs">{p.destination ?? '—'}</td>
<td className="py-2 pr-4 font-mono text-xs">{p.bookingRef}</td>
<td className="py-2">
<span className={`text-xs font-semibold px-1.5 py-0.5 rounded ${p.bookingStatus === 'BOARDED' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'}`}>
{p.bookingStatus}
</span>
</td>
<div className="card p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
<th className="px-4 py-3">#</th>
<th className="px-4 py-3">Name</th>
<th className="px-4 py-3">Coach · Seat</th>
<th className="px-4 py-3">Origin</th>
<th className="px-4 py-3">Destination</th>
<th className="px-4 py-3">Date</th>
<th className="px-4 py-3">Booking Ref</th>
</tr>
))}
{filteredList.length === 0 && (
<tr><td colSpan={9} className="py-8 text-center text-sm text-muted-foreground">No passengers found</td></tr>
)}
</tbody>
</table>
</thead>
<tbody className="divide-y divide-border">
{filteredList.map((p, i) => (
<tr key={`${p.bookingRef}-${i}`} className="hover:bg-muted/30">
<td className="px-4 py-3 text-muted-foreground tabular-nums">{i + 1}</td>
<td className="px-4 py-3 font-medium">{p.passengerName}</td>
<td className="px-4 py-3 font-mono text-xs">{p.coachSeat}</td>
<td className="px-4 py-3 text-muted-foreground">{p.origin}</td>
<td className="px-4 py-3 text-muted-foreground">{p.destination}</td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">{p.departureAt ? formatDateTime(p.departureAt) : '—'}</td>
<td className="px-4 py-3 font-mono text-xs">{p.bookingRef}</td>
</tr>
))}
{filteredList.length === 0 && (
<tr><td colSpan={7} className="py-8 text-center text-sm text-muted-foreground">No passengers found</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)}

View File

@@ -2,8 +2,8 @@
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download, Armchair, CheckCircle, Clock, AlertCircle } from 'lucide-react';
import { bookingsApi } from '@/lib/api';
import { Download, Armchair, CheckCircle, Clock, AlertCircle, Ban } from 'lucide-react';
import { bookingsApi, seatsApi } from '@/lib/api';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import { formatDateTime, formatCurrency } from '@/lib/utils';
@@ -46,6 +46,11 @@ export default function SeatStatusReportPage() {
const [statusFilter, setStatusFilter] = useState<'ALL' | 'PAID' | 'UNPAID'>('ALL');
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({
queryKey: ['seat-report-bookings'],
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
@@ -150,7 +155,7 @@ export default function SeatStatusReportPage() {
</div>
{/* 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="flex items-start justify-between">
<div>
@@ -189,6 +194,19 @@ export default function SeatStatusReportPage() {
<AlertCircle className="h-8 w-8 text-red-500 opacity-30" />
</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>
{/* Filters */}

View File

@@ -151,6 +151,7 @@ export const seatsApi = {
return apiClient.get<any>(`/seats/seatmap/${scheduleId}${params}`);
},
getBySchedule: (scheduleId: string) => apiClient.get<any>(`/seats/schedule/${scheduleId}`),
getBlocked: () => apiClient.get<any[]>('/seats/blocks'),
hold: (data: any) => apiClient.post<any>('/seats/hold', data),
release: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`),
block: (seatId: string, data: any) => apiClient.post<any>(`/seats/${seatId}/block`, data),