Package pricing issue resolution, passenger report, tickets filter updates

This commit is contained in:
Stephanos A
2026-07-17 23:18:52 +03:00
parent 2567e25173
commit f2c037a5b7
10 changed files with 510 additions and 38 deletions

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../../dashboard/layout';
export default function PassengersLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -0,0 +1,315 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Users, Armchair, TrendingUp, Train, Download } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
import { apiClient } from '@/lib/api-client';
import { formatDateTime } from '@/lib/utils';
import ActionButton from '@/components/ui/ActionButton';
const COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4'];
function StatCard({ label, value, sub, icon: Icon, color }: { label: string; value: string | number; sub?: string; icon: any; color: string }) {
return (
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{label}</p>
<div className={`rounded-lg p-1.5 ${color}`}><Icon className="h-4 w-4" /></div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">{value}</p>
{sub && <p className="text-xs text-muted-foreground">{sub}</p>}
</div>
);
}
export default function PassengersReportPage() {
const [scheduleId, setScheduleId] = useState('');
const { data: schedules = [] } = useQuery<any[]>({
queryKey: ['schedules-list'],
queryFn: () => apiClient.get('/schedules'),
select: (d: any) => d?.items ?? (Array.isArray(d) ? d : []),
});
const { data, isFetching } = useQuery({
queryKey: ['occupancy-report', scheduleId],
queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`),
enabled: !!scheduleId,
});
const report = data as any;
const doExport = () => {
if (!report) return;
const rows = [
['Coach', 'Type', 'Total Seats', 'Booked', 'Occupancy %'],
...report.byCoach.map((c: any) => [c.coachNumber, c.coachType, c.totalSeats, c.booked, c.occupancyRate]),
];
const csv = rows.map(r => r.map((v: any) => `"${v}"`).join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `occupancy-${scheduleId}-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Passengers Report</h1>
<p className="text-muted-foreground mt-1">Select a schedule to view passenger occupancy breakdown</p>
</div>
{/* Schedule Selector */}
<div className="card">
<div className="flex flex-wrap items-end gap-4">
<div className="flex-1 min-w-64">
<label className="label">Schedule</label>
<select
className="input"
value={scheduleId}
onChange={(e) => setScheduleId(e.target.value)}
>
<option value=""> Select a schedule </option>
{schedules.map((s: any) => (
<option key={s.id} value={s.id}>
{s.train?.name ?? s.train?.number ?? 'Train'} · {s.originStation?.name} {s.destinationStation?.name} · {s.departureAt ? new Date(s.departureAt).toLocaleString() : ''}
</option>
))}
</select>
</div>
{isFetching && <p className="text-sm text-muted-foreground self-center">Loading</p>}
{report && (
<ActionButton icon={Download} variant="secondary" onClick={doExport}>
Export CSV
</ActionButton>
)}
</div>
</div>
{isFetching && (
<div className="card py-12 text-center text-muted-foreground">Loading passengers data</div>
)}
{report && (
<>
{/* Schedule Info */}
<div className="card flex items-center gap-4">
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-2.5">
<Train className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<p className="font-semibold">{report.schedule.trainName}</p>
<p className="text-sm text-muted-foreground">
{report.schedule.origin} {report.schedule.destination} · Departure: {formatDateTime(report.schedule.departureAt)}
</p>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<StatCard
label="Total Seats"
value={report.summary.totalSeats}
icon={Armchair}
color="bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400"
/>
<StatCard
label="Total Passengers"
value={report.summary.totalPassengers}
icon={Users}
color="bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400"
/>
<StatCard
label="Occupancy Rate"
value={`${report.summary.occupancyRate}%`}
sub={`${report.summary.totalSeats - report.summary.totalPassengers} seats available`}
icon={TrendingUp}
color="bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400"
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* By Coach */}
<div className="card">
<h3 className="text-base font-semibold mb-4">Occupancy by Coach</h3>
{report.byCoach.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={report.byCoach} layout="vertical" margin={{ left: 8 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" domain={[0, 100]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11 }} />
<YAxis type="category" dataKey="coachNumber" tick={{ fontSize: 11 }} width={56} tickFormatter={(v) => `Coach ${v}`} />
<Tooltip formatter={(v: number) => [`${v}%`, 'Occupancy']} />
<Bar dataKey="occupancyRate" radius={[0, 3, 3, 0]}>
{report.byCoach.map((_: any, i: number) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<table className="w-full mt-3 text-sm">
<thead>
<tr className="text-xs text-muted-foreground border-b border-border">
<th className="text-left py-1.5 font-medium">Coach</th>
<th className="text-left py-1.5 font-medium">Type</th>
<th className="text-right py-1.5 font-medium">Booked</th>
<th className="text-right py-1.5 font-medium">Total</th>
<th className="text-right py-1.5 font-medium">Rate</th>
</tr>
</thead>
<tbody>
{report.byCoach.map((c: any, i: number) => (
<tr key={i} className="border-b border-border/50 last:border-0">
<td className="py-1.5 font-mono font-semibold">Coach {c.coachNumber}</td>
<td className="py-1.5 text-muted-foreground">{c.coachType}</td>
<td className="py-1.5 text-right tabular-nums">{c.booked}</td>
<td className="py-1.5 text-right tabular-nums text-muted-foreground">{c.totalSeats}</td>
<td className="py-1.5 text-right tabular-nums font-semibold">{c.occupancyRate}%</td>
</tr>
))}
</tbody>
</table>
</>
) : (
<p className="text-sm text-muted-foreground">No coach data</p>
)}
</div>
{/* By Class */}
<div className="card">
<h3 className="text-base font-semibold mb-4">Occupancy by Class</h3>
{report.byClass.length > 0 ? (
<>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={report.byClass}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="className" tick={{ fontSize: 11 }} />
<YAxis domain={[0, 100]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11 }} />
<Tooltip formatter={(v: number) => [`${v}%`, 'Occupancy']} />
<Bar dataKey="occupancyRate" radius={[3, 3, 0, 0]}>
{report.byClass.map((_: any, i: number) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
<table className="w-full mt-3 text-sm">
<thead>
<tr className="text-xs text-muted-foreground border-b border-border">
<th className="text-left py-1.5 font-medium">Class</th>
<th className="text-right py-1.5 font-medium">Booked</th>
<th className="text-right py-1.5 font-medium">Total</th>
<th className="text-right py-1.5 font-medium">Rate</th>
</tr>
</thead>
<tbody>
{report.byClass.map((c: any, i: number) => (
<tr key={i} className="border-b border-border/50 last:border-0">
<td className="py-1.5 font-medium">{c.className}</td>
<td className="py-1.5 text-right tabular-nums">{c.booked}</td>
<td className="py-1.5 text-right tabular-nums text-muted-foreground">{c.totalSeats}</td>
<td className="py-1.5 text-right tabular-nums font-semibold">{c.occupancyRate}%</td>
</tr>
))}
</tbody>
</table>
</>
) : (
<p className="text-sm text-muted-foreground">No class data</p>
)}
</div>
{/* By Origin */}
<div className="card">
<h3 className="text-base font-semibold mb-4">Passengers by Boarding Station</h3>
{report.byOrigin.length > 0 ? (
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-muted-foreground border-b border-border">
<th className="text-left py-1.5 font-medium">Station</th>
<th className="text-right py-1.5 font-medium">Passengers</th>
<th className="text-right py-1.5 font-medium">Share</th>
</tr>
</thead>
<tbody>
{report.byOrigin.map((o: any, i: number) => {
const pct = report.summary.totalPassengers > 0
? ((o.passengers / report.summary.totalPassengers) * 100).toFixed(1)
: '0';
return (
<tr key={i} className="border-b border-border/50 last:border-0">
<td className="py-2">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: COLORS[i % COLORS.length] }} />
{o.stationName}
</div>
</td>
<td className="py-2 text-right tabular-nums font-semibold">{o.passengers}</td>
<td className="py-2 text-right tabular-nums text-muted-foreground">{pct}%</td>
</tr>
);
})}
</tbody>
</table>
) : (
<p className="text-sm text-muted-foreground">No boarding station data</p>
)}
</div>
{/* By Destination */}
<div className="card">
<h3 className="text-base font-semibold mb-4">Passengers by Alighting Station</h3>
{report.byDestination.length > 0 ? (
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-muted-foreground border-b border-border">
<th className="text-left py-1.5 font-medium">Station</th>
<th className="text-right py-1.5 font-medium">Passengers</th>
<th className="text-right py-1.5 font-medium">Share</th>
</tr>
</thead>
<tbody>
{report.byDestination.map((d: any, i: number) => {
const pct = report.summary.totalPassengers > 0
? ((d.passengers / report.summary.totalPassengers) * 100).toFixed(1)
: '0';
return (
<tr key={i} className="border-b border-border/50 last:border-0">
<td className="py-2">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: COLORS[i % COLORS.length] }} />
{d.stationName}
</div>
</td>
<td className="py-2 text-right tabular-nums font-semibold">{d.passengers}</td>
<td className="py-2 text-right tabular-nums text-muted-foreground">{pct}%</td>
</tr>
);
})}
</tbody>
</table>
) : (
<p className="text-sm text-muted-foreground">No alighting station data</p>
)}
</div>
</div>
</>
)}
{!report && !isFetching && scheduleId && (
<div className="card py-12 text-center text-muted-foreground">No data found for this schedule.</div>
)}
{!scheduleId && (
<div className="card py-16 text-center text-muted-foreground">
<Armchair className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>Select a schedule above to load the occupancy report</p>
</div>
)}
</div>
);
}

View File

@@ -15,7 +15,7 @@ import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils
import { useAuthStore } from '@/lib/auth-store';
export default function TicketsPage() {
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '', dateFrom: '', dateTo: '', coachId: '' });
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', departureDate: '', arrivalDate: '', dateFrom: '', dateTo: '', coachId: '' });
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
@@ -71,6 +71,7 @@ export default function TicketsPage() {
status: filters.status || undefined,
originStationId: filters.originStationId || undefined,
destinationStationId: filters.destinationStationId || undefined,
departureDate: filters.departureDate || undefined,
arrivalDate: filters.arrivalDate || undefined,
dateFrom: filters.dateFrom || undefined,
dateTo: filters.dateTo || undefined,
@@ -358,11 +359,13 @@ export default function TicketsPage() {
render: (ticket: any) => {
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
const returnDeparture = ticket.booking?.returnSchedule?.departureAt;
const origin = ticket.booking?.originStation?.name || ticket.schedule?.originStation?.name || 'N/A';
const destination = ticket.booking?.destinationStation?.name || ticket.schedule?.destinationStation?.name || 'N/A';
return (
<div>
<div className="font-medium">
{ticket.schedule?.originStation?.name || 'N/A'} {ticket.schedule?.destinationStation?.name || 'N/A'}
{origin} {destination}
</div>
<div className="text-xs text-muted-foreground">
{!isRoundTrip ? (
@@ -551,7 +554,7 @@ export default function TicketsPage() {
Error loading tickets: {error instanceof Error ? error.message : 'Unknown error'}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div>
<label className="label">Search</label>
<input
@@ -589,14 +592,27 @@ export default function TicketsPage() {
</select>
</div>
<div>
<label className="label">Arrival Date</label>
<label className="label">Departure Date</label>
<input
type="date"
className="input"
value={filters.arrivalDate}
onChange={(e) => setFilters({ ...filters, arrivalDate: e.target.value })}
value={filters.departureDate}
onChange={(e) => setFilters({ ...filters, departureDate: e.target.value })}
/>
</div>
<div>
<label className="label">Coach</label>
<select
className="input"
value={filters.coachId}
onChange={(e) => setFilters({ ...filters, coachId: e.target.value })}
>
<option value="">All Coaches</option>
{(Array.isArray(coachesData) ? coachesData : (coachesData as any)?.data || []).map((coach: any) => (
<option key={coach.id} value={coach.id}>{coach.number}</option>
))}
</select>
</div>
<div className="flex items-end">
<button type="button" className="input w-full px-4 text-sm font-medium text-primary border-primary/40"
onClick={() => setShowExtraFilters(v => !v)}>
@@ -605,7 +621,7 @@ export default function TicketsPage() {
</div>
</div>
{showExtraFilters && (
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3 mt-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-3">
<div>
<label className="label">Status</label>
<select
@@ -619,19 +635,6 @@ export default function TicketsPage() {
<option value="CANCELLED">Cancelled</option>
</select>
</div>
<div>
<label className="label">Coach</label>
<select
className="input"
value={filters.coachId}
onChange={(e) => setFilters({ ...filters, coachId: e.target.value })}
>
<option value="">All Coaches</option>
{(Array.isArray(coachesData) ? coachesData : (coachesData as any)?.data || []).map((coach: any) => (
<option key={coach.id} value={coach.id}>{coach.number}</option>
))}
</select>
</div>
<div>
<label className="label">Issued From</label>
<input type="date" className="input" value={filters.dateFrom}
@@ -794,8 +797,8 @@ export default function TicketsPage() {
<section>
<SectionHeader title="Trip Information" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Field label="Origin" value={t.schedule?.originStation?.name} />
<Field label="Destination" value={t.schedule?.destinationStation?.name} />
<Field label="Origin" value={t.booking?.originStation?.name || t.schedule?.originStation?.name} />
<Field label="Destination" value={t.booking?.destinationStation?.name || t.schedule?.destinationStation?.name} />
<Field label="Departure" value={t.schedule?.departureAt ? formatDateTime(t.schedule.departureAt) : ''} />
<Field label="Arrival" value={t.schedule?.arrivalAt ? formatDateTime(t.schedule.arrivalAt) : ''} />
<Field label="Train" value={t.schedule?.train?.name || t.schedule?.train?.number} />

View File

@@ -121,8 +121,9 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{
title: 'Analytics & Reports',
items: [
{ name: 'Overall', href: '/reports', icon: BarChart3, permission: PERMS.reports.view },
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
{ name: 'Overall', href: '/reports', icon: BarChart3, permission: PERMS.reports.view },
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view },
// { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },
]
},