mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 05:25:41 +00:00
Merge branch 'dev' into quick-fix
This commit is contained in:
@@ -99,7 +99,8 @@ function DashboardPageContent() {
|
||||
queryKey: ['backoffice-stats'],
|
||||
queryFn: dashboardApi.getBackofficeStats,
|
||||
retry: 1,
|
||||
staleTime: 60000,
|
||||
staleTime: 30000,
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
|
||||
const { data: paymentMethods } = useQuery({
|
||||
@@ -150,10 +151,11 @@ function DashboardPageContent() {
|
||||
</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"
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
|
||||
title="Boarding / Gate Scan"
|
||||
>
|
||||
<ScanLine className="h-4 w-4" />
|
||||
Boarding
|
||||
<ScanLine className="h-4 w-4 text-[rgb(20,113,76)]" />
|
||||
<span className="hidden sm:inline">Boarding</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function DiscrepancyLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
519
apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx
Normal file
519
apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { Search, Layers, ChevronDown, ChevronUp, CheckSquare, Square, AlertCircle, CheckCircle2, X, Loader2 } from 'lucide-react';
|
||||
import { seatsApi } from '@/lib/api';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DuplicateBooking {
|
||||
bookingSeatId: string;
|
||||
bookingId: string;
|
||||
bookingRef: string;
|
||||
passengerName: string;
|
||||
contactPhone: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface DuplicateSeatGroup {
|
||||
seatId: string;
|
||||
seatNumber: string;
|
||||
leg: number;
|
||||
bookings: DuplicateBooking[];
|
||||
}
|
||||
|
||||
interface AvailableSeat {
|
||||
seatId: string;
|
||||
seatNumber: string;
|
||||
}
|
||||
|
||||
interface CoachReport {
|
||||
coachId: string;
|
||||
coachNumber: string;
|
||||
coachTypeName: string;
|
||||
duplicates: DuplicateSeatGroup[];
|
||||
availableSeats: AvailableSeat[];
|
||||
}
|
||||
|
||||
interface ScheduleReport {
|
||||
scheduleId: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
departureAt: string;
|
||||
coaches: CoachReport[];
|
||||
}
|
||||
|
||||
interface DuplicatesResponse {
|
||||
date: string;
|
||||
schedules: ScheduleReport[];
|
||||
totalDuplicates: number;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function scheduleDuplicateCount(s: ScheduleReport) {
|
||||
return s.coaches.reduce((sum, c) => sum + c.duplicates.length, 0);
|
||||
}
|
||||
|
||||
// ── Resolve modal ─────────────────────────────────────────────────────────
|
||||
|
||||
interface ResolveModalProps {
|
||||
schedule: ScheduleReport;
|
||||
coach: CoachReport;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function ResolveModal({ schedule, coach, onClose, onSuccess }: ResolveModalProps) {
|
||||
// Default: pre-select all-but-first passenger in every duplicate group
|
||||
const defaultSelected = new Set<string>(
|
||||
coach.duplicates.flatMap(g => g.bookings.slice(1).map(b => b.bookingSeatId)),
|
||||
);
|
||||
const [selectedSeats, setSelectedSeats] = useState<Set<string>>(defaultSelected);
|
||||
|
||||
// Coaches that have at least one available seat (pre-select all)
|
||||
const coachesWithSeats = schedule.coaches.filter(c => c.availableSeats.length > 0);
|
||||
const [selectedCoachIds, setSelectedCoachIds] = useState<Set<string>>(
|
||||
new Set(coachesWithSeats.map(c => c.coachId)),
|
||||
);
|
||||
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: { bookingSeatIds: string[]; coachIds: string[] }) =>
|
||||
seatsApi.resolveDuplicates(data),
|
||||
onSuccess: (res) => {
|
||||
setSuccessMsg(
|
||||
`${res.resolved ?? 0} passenger(s) successfully reassigned.` +
|
||||
(res.unresolved > 0 ? ` ${res.unresolved} could not be resolved (no available seat).` : ''),
|
||||
);
|
||||
setErrorMsg(null);
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setErrorMsg(err?.response?.data?.message ?? 'Failed to resolve duplicates.');
|
||||
},
|
||||
});
|
||||
|
||||
function toggleBookingSeat(id: string) {
|
||||
setSelectedSeats(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCoach(id: string) {
|
||||
setSelectedCoachIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleAssign() {
|
||||
setErrorMsg(null);
|
||||
if (selectedSeats.size === 0) {
|
||||
setErrorMsg('Select at least one passenger to reassign.');
|
||||
return;
|
||||
}
|
||||
if (selectedCoachIds.size === 0) {
|
||||
setErrorMsg('Select at least one coach to source the replacement seat from.');
|
||||
return;
|
||||
}
|
||||
mutation.mutate({
|
||||
bookingSeatIds: [...selectedSeats],
|
||||
coachIds: [...selectedCoachIds],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-xl bg-white dark:bg-gray-900 shadow-2xl flex flex-col">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Resolve Duplicates — {coach.coachNumber}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{schedule.origin} → {schedule.destination} · {formatDateTime(schedule.departureAt)}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-6">
|
||||
|
||||
{/* Duplicate seat groups */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
|
||||
Duplicate seat assignments
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Check the passengers you want to reassign to a new seat. Unchecked passengers keep their current seat.
|
||||
</p>
|
||||
|
||||
{coach.duplicates.map(group => (
|
||||
<div
|
||||
key={`${group.seatId}-${group.leg}`}
|
||||
className="rounded-lg border border-orange-200 dark:border-orange-800 bg-orange-50 dark:bg-orange-950/30 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<AlertCircle className="w-4 h-4 text-orange-500 shrink-0" />
|
||||
<span className="text-sm font-medium text-orange-800 dark:text-orange-300">
|
||||
Seat {group.seatNumber} — {group.bookings.length} passengers assigned
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{group.bookings.map((b, idx) => {
|
||||
const checked = selectedSeats.has(b.bookingSeatId);
|
||||
return (
|
||||
<label
|
||||
key={b.bookingSeatId}
|
||||
className="flex items-start gap-3 cursor-pointer rounded-lg px-3 py-2 hover:bg-orange-100 dark:hover:bg-orange-900/30 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleBookingSeat(b.bookingSeatId)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{b.passengerName || '—'}
|
||||
</span>
|
||||
<span className="text-xs font-mono bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 px-1.5 py-0.5 rounded">
|
||||
{b.bookingRef}
|
||||
</span>
|
||||
{idx === 0 && (
|
||||
<span className="text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-1.5 py-0.5 rounded">
|
||||
earliest
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{b.contactPhone ?? 'No phone'} · Booked {formatDateTime(b.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Coach selection */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
|
||||
Reassign to seats in
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
The system picks the first available seat in the selected coaches.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{coachesWithSeats.length === 0 ? (
|
||||
<p className="text-sm text-red-500">No coaches have available seats on this schedule.</p>
|
||||
) : (
|
||||
coachesWithSeats.map(c => (
|
||||
<label
|
||||
key={c.coachId}
|
||||
className="flex items-center gap-3 cursor-pointer rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCoachIds.has(c.coachId)}
|
||||
onChange={() => toggleCoach(c.coachId)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{c.coachNumber}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
|
||||
{c.coachTypeName}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
|
||||
{c.availableSeats.length} available
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
{errorMsg && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-4 py-3">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{errorMsg}</p>
|
||||
</div>
|
||||
)}
|
||||
{successMsg && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 px-4 py-3">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-green-700 dark:text-green-400">{successMsg}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{successMsg ? 'Close' : 'Cancel'}
|
||||
</button>
|
||||
{!successMsg && (
|
||||
<button
|
||||
onClick={handleAssign}
|
||||
disabled={mutation.isPending || selectedSeats.size === 0 || selectedCoachIds.size === 0}
|
||||
className="flex items-center gap-2 px-5 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{mutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Assign {selectedSeats.size > 0 ? `${selectedSeats.size} passenger${selectedSeats.size > 1 ? 's' : ''}` : ''}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Coach card ────────────────────────────────────────────────────────────
|
||||
|
||||
interface CoachCardProps {
|
||||
coach: CoachReport;
|
||||
schedule: ScheduleReport;
|
||||
onResolve: () => void;
|
||||
}
|
||||
|
||||
function CoachCard({ coach, schedule, onResolve }: CoachCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasDuplicates = coach.duplicates.length > 0;
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border ${hasDuplicates ? 'border-orange-200 dark:border-orange-800' : 'border-gray-200 dark:border-gray-700'} bg-white dark:bg-gray-900 overflow-hidden`}>
|
||||
{/* Card header */}
|
||||
<div className="flex items-center gap-4 px-5 py-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-gray-900 dark:text-white">{coach.coachNumber}</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{coach.coachTypeName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{coach.availableSeats.length} available seats</span>
|
||||
{hasDuplicates && (
|
||||
<span className="flex items-center gap-1 text-orange-600 dark:text-orange-400 font-medium">
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
{coach.duplicates.length} duplicate{coach.duplicates.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{hasDuplicates && (
|
||||
<button
|
||||
onClick={onResolve}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-orange-500 text-white hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
)}
|
||||
{hasDuplicates && (
|
||||
<button
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 transition-colors"
|
||||
title={expanded ? 'Collapse' : 'View passengers'}
|
||||
>
|
||||
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded passenger list */}
|
||||
{expanded && hasDuplicates && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{coach.duplicates.map(group => (
|
||||
<div key={`${group.seatId}-${group.leg}`} className="px-5 py-3">
|
||||
<p className="text-xs font-semibold text-orange-600 dark:text-orange-400 mb-2">
|
||||
Seat {group.seatNumber} — {group.bookings.length} passengers
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{group.bookings.map((b, idx) => (
|
||||
<div key={b.bookingSeatId} className="flex items-center gap-3 text-sm">
|
||||
<span className="w-5 h-5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-xs flex items-center justify-center font-medium shrink-0">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-medium text-gray-900 dark:text-white">{b.passengerName || '—'}</span>
|
||||
<span className="ml-2 text-xs font-mono text-gray-500 dark:text-gray-400">{b.bookingRef}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">{b.contactPhone ?? '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DiscrepancyPage() {
|
||||
const [date, setDate] = useState(today());
|
||||
const [searchDate, setSearchDate] = useState('');
|
||||
const [resolveTarget, setResolveTarget] = useState<{ schedule: ScheduleReport; coach: CoachReport } | null>(null);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery<DuplicatesResponse>({
|
||||
queryKey: ['seat-duplicates', searchDate],
|
||||
queryFn: () => seatsApi.getDuplicates(searchDate),
|
||||
enabled: !!searchDate,
|
||||
});
|
||||
|
||||
function handleSearch() {
|
||||
if (date) setSearchDate(date);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-5xl mx-auto">
|
||||
|
||||
{/* Page header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-orange-100 dark:bg-orange-950/40">
|
||||
<Layers className="w-6 h-6 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Seat Discrepancy</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Detect and resolve duplicate seat assignments by schedule date
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date picker */}
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={e => setDate(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={!date || isLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{isError && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-4 py-3 text-sm text-red-700 dark:text-red-400">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
Failed to load duplicate seat data. Please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary banner */}
|
||||
{data && (
|
||||
<div className={`rounded-xl border px-5 py-4 flex items-center gap-3 ${
|
||||
data.totalDuplicates > 0
|
||||
? 'bg-orange-50 dark:bg-orange-950/20 border-orange-200 dark:border-orange-800'
|
||||
: 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800'
|
||||
}`}>
|
||||
{data.totalDuplicates > 0 ? (
|
||||
<AlertCircle className="w-5 h-5 text-orange-500 shrink-0" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500 shrink-0" />
|
||||
)}
|
||||
<span className={`text-sm font-medium ${
|
||||
data.totalDuplicates > 0
|
||||
? 'text-orange-800 dark:text-orange-300'
|
||||
: 'text-green-800 dark:text-green-300'
|
||||
}`}>
|
||||
{data.totalDuplicates > 0
|
||||
? `${data.totalDuplicates} duplicate seat assignment${data.totalDuplicates > 1 ? 's' : ''} found across ${data.schedules.length} schedule${data.schedules.length > 1 ? 's' : ''} on ${data.date}`
|
||||
: `No duplicate seat assignments found on ${data.date}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results per schedule */}
|
||||
{data?.schedules.map(schedule => (
|
||||
<div key={schedule.scheduleId} className="space-y-3">
|
||||
{/* Schedule header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{schedule.origin} → {schedule.destination}
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatDateTime(schedule.departureAt)} · {scheduleDuplicateCount(schedule)} duplicate{scheduleDuplicateCount(schedule) !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coach cards grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{schedule.coaches.map(coach => (
|
||||
<CoachCard
|
||||
key={coach.coachId}
|
||||
coach={coach}
|
||||
schedule={schedule}
|
||||
onResolve={() => setResolveTarget({ schedule, coach })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Empty state when searched but no results */}
|
||||
{data && data.schedules.length === 0 && data.totalDuplicates === 0 && searchDate && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<CheckCircle2 className="w-12 h-12 text-green-400 mb-3" />
|
||||
<p className="text-gray-500 dark:text-gray-400">All seats are correctly assigned for {data.date}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resolve modal */}
|
||||
{resolveTarget && (
|
||||
<ResolveModal
|
||||
schedule={resolveTarget.schedule}
|
||||
coach={resolveTarget.coach}
|
||||
onClose={() => setResolveTarget(null)}
|
||||
onSuccess={() => {
|
||||
refetch();
|
||||
// Keep modal open to show success message; user closes manually
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -365,7 +365,7 @@ export default function LoginPage() {
|
||||
Back-office · v1.0
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-600">
|
||||
Need help? <a href="mailto:support@edr.com" className="text-[rgb(20,113,76)] hover:underline">support@edr.com</a>
|
||||
Need help? <a href="mailto:edr_@edrsc.com" className="text-[rgb(20,113,76)] hover:underline">edr_@edrsc.com</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
import DashboardLayout from '../../dashboard/layout';
|
||||
|
||||
export default function PassengersLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -2,20 +2,25 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Users, Armchair, BarChart3, Download } from 'lucide-react';
|
||||
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 ActionButton from '@/components/ui/ActionButton';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
|
||||
interface ScheduleOption { id: string; label: string; }
|
||||
const COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4'];
|
||||
|
||||
interface PassengersReport {
|
||||
schedule: { id: string; trainName: string; origin: string; destination: string; departureAt: string; arrivalAt: string; };
|
||||
summary: { totalSeats: number; totalPassengers: number; occupancyRate: number };
|
||||
byCoach: { coachNumber: string; coachType: string; totalSeats: number; booked: number; occupancyRate: number }[];
|
||||
byClass: { className: string; totalSeats: number; booked: number; occupancyRate: number }[];
|
||||
byOrigin: { stationName: string; passengers: number }[];
|
||||
byDestination: { stationName: string; passengers: number }[];
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
interface PassengerRow {
|
||||
@@ -31,39 +36,34 @@ type Tab = 'occupancy' | 'list';
|
||||
|
||||
export default function PassengersReportPage() {
|
||||
const [scheduleId, setScheduleId] = useState('');
|
||||
const [tab, setTab] = useState<Tab>('occupancy');
|
||||
const [listSearch, setListSearch] = useState('');
|
||||
|
||||
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<ScheduleOption[]>({
|
||||
queryKey: ['report-schedules'],
|
||||
queryFn: () => apiClient.get('/reports/schedules'),
|
||||
const { data: schedules = [] } = useQuery<any[]>({
|
||||
queryKey: ['schedules-list'],
|
||||
queryFn: () => apiClient.get('/schedules'),
|
||||
select: (d: any) => d?.items ?? (Array.isArray(d) ? d : []),
|
||||
});
|
||||
const schedules = schedulesRaw ?? [];
|
||||
|
||||
const { data, isLoading, isError } = useQuery<PassengersReport>({
|
||||
queryKey: ['passengers-report', scheduleId],
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: ['occupancy-report', scheduleId],
|
||||
queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`),
|
||||
enabled: !!scheduleId,
|
||||
});
|
||||
|
||||
const { data: passengerList = [], isLoading: listLoading } = useQuery<PassengerRow[]>({
|
||||
queryKey: ['passengers-list', scheduleId],
|
||||
queryFn: () => apiClient.get(`/reports/passengers/list?scheduleId=${scheduleId}`),
|
||||
enabled: !!scheduleId,
|
||||
});
|
||||
const report = data as any;
|
||||
|
||||
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 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 = filename; a.click();
|
||||
a.href = url;
|
||||
a.download = `occupancy-${scheduleId}-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
@@ -87,226 +87,257 @@ export default function PassengersReportPage() {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Passengers Report</h1>
|
||||
<p className="text-muted-foreground mt-1">Occupancy and passenger breakdown for a schedule</p>
|
||||
<p className="text-muted-foreground mt-1">Select a schedule to view passenger occupancy breakdown</p>
|
||||
</div>
|
||||
|
||||
{/* Schedule selector */}
|
||||
{/* Schedule Selector */}
|
||||
<div className="card">
|
||||
<div className="flex items-end gap-4 flex-wrap">
|
||||
<div className="flex-1 min-w-72">
|
||||
<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); setTab('occupancy'); setListSearch(''); }}
|
||||
disabled={loadingSchedules}
|
||||
onChange={(e) => setScheduleId(e.target.value)}
|
||||
>
|
||||
<option value="">{loadingSchedules ? 'Loading schedules…' : 'Select a schedule…'}</option>
|
||||
{schedules.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.label}</option>
|
||||
<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>
|
||||
{data && tab === 'occupancy' && (
|
||||
<ActionButton icon={Download} variant="secondary" onClick={doExportOccupancy}>Export CSV</ActionButton>
|
||||
{isFetching && <p className="text-sm text-muted-foreground self-center">Loading…</p>}
|
||||
{report && (
|
||||
<ActionButton icon={Download} variant="secondary" onClick={doExport}>
|
||||
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>}
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
{isFetching && (
|
||||
<div className="card py-12 text-center text-muted-foreground">Loading passengers data…</div>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<>
|
||||
{/* Schedule info */}
|
||||
<div className="card">
|
||||
<p className="text-sm font-semibold text-muted-foreground uppercase tracking-wider mb-3">Schedule</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 text-sm">
|
||||
<div><span className="text-muted-foreground">Train</span><p className="font-semibold">{data.schedule.trainName ?? '—'}</p></div>
|
||||
<div><span className="text-muted-foreground">Route</span><p className="font-semibold">{data.schedule.origin} → {data.schedule.destination}</p></div>
|
||||
<div><span className="text-muted-foreground">Departure</span><p className="font-semibold">{formatDateTime(data.schedule.departureAt)}</p></div>
|
||||
{/* 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>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-border flex">
|
||||
<button
|
||||
onClick={() => setTab('occupancy')}
|
||||
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === 'occupancy' ? 'border-emerald-500 text-emerald-600 dark:text-emerald-400' : 'border-transparent text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Occupancy
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab('list')}
|
||||
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === 'list' ? 'border-emerald-500 text-emerald-600 dark:text-emerald-400' : 'border-transparent text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
Passenger List{passengerList.length > 0 ? ` (${passengerList.length})` : ''}
|
||||
</button>
|
||||
{/* 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>
|
||||
|
||||
{/* Occupancy tab */}
|
||||
{tab === 'occupancy' && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<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">Total Seats</p>
|
||||
<div className="rounded-lg bg-blue-100 dark:bg-blue-900/30 p-1.5"><Armchair className="h-4 w-4 text-blue-600 dark:text-blue-400" /></div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">{data.summary.totalSeats}</p>
|
||||
</div>
|
||||
<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">Passengers</p>
|
||||
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-1.5"><Users className="h-4 w-4 text-emerald-600 dark:text-emerald-400" /></div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">{data.summary.totalPassengers}</p>
|
||||
</div>
|
||||
<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">Occupancy Rate</p>
|
||||
<div className="rounded-lg bg-purple-100 dark:bg-purple-900/30 p-1.5"><BarChart3 className="h-4 w-4 text-purple-600 dark:text-purple-400" /></div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">{data.summary.occupancyRate}%</p>
|
||||
<div className="w-full bg-muted rounded-full h-1.5 mt-1">
|
||||
<div className="bg-purple-500 h-1.5 rounded-full" style={{ width: `${data.summary.occupancyRate}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Coach</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<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="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
|
||||
<th className="pb-2 pr-4">Coach</th>
|
||||
<th className="pb-2 pr-4">Type</th>
|
||||
<th className="pb-2 pr-4 text-right">Seats</th>
|
||||
<th className="pb-2 pr-4 text-right">Booked</th>
|
||||
<th className="pb-2">Occupancy</th>
|
||||
<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 className="divide-y divide-border">
|
||||
{data.byCoach.map(c => (
|
||||
<tr key={c.coachNumber} className="hover:bg-muted/30">
|
||||
<td className="py-2 pr-4 font-semibold">{c.coachNumber}</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground">{c.coachType}</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">{c.totalSeats}</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">{c.booked}</td>
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-muted rounded-full h-1.5">
|
||||
<div className="bg-emerald-500 h-1.5 rounded-full" style={{ width: `${c.occupancyRate}%` }} />
|
||||
</div>
|
||||
<span className="tabular-nums text-xs w-10 text-right">{c.occupancyRate}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No coach data</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Class</h3>
|
||||
<div className="space-y-3">
|
||||
{data.byClass.map(c => (
|
||||
<div key={c.className}>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="font-medium">{c.className}</span>
|
||||
<span className="tabular-nums text-muted-foreground">{c.booked}/{c.totalSeats}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-muted rounded-full h-1.5">
|
||||
<div className="bg-blue-500 h-1.5 rounded-full" style={{ width: `${c.occupancyRate}%` }} />
|
||||
</div>
|
||||
<span className="text-xs tabular-nums w-10 text-right">{c.occupancyRate}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Boarding Station</h3>
|
||||
<div className="space-y-2">
|
||||
{data.byOrigin.map(o => (
|
||||
<div key={o.stationName} className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground truncate">{o.stationName}</span>
|
||||
<span className="font-semibold tabular-nums ml-2">{o.passengers}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.byOrigin.length === 0 && <p className="text-xs text-muted-foreground">No data</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Alighting Station</h3>
|
||||
<div className="space-y-2">
|
||||
{data.byDestination.map(d => (
|
||||
<div key={d.stationName} className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground truncate">{d.stationName}</span>
|
||||
<span className="font-semibold tabular-nums ml-2">{d.passengers}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.byDestination.length === 0 && <p className="text-xs text-muted-foreground">No data</p>}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{tab === 'list' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
className="input max-w-sm flex-1"
|
||||
placeholder="Search by name or booking ref…"
|
||||
value={listSearch}
|
||||
onChange={e => setListSearch(e.target.value)}
|
||||
/>
|
||||
{passengerList.length > 0 && (
|
||||
<ActionButton icon={Download} variant="secondary" onClick={doExportList}>Export CSV</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
{/* 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>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No alighting station data</p>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Download, Armchair, CheckCircle, Clock, AlertCircle, Ban } from 'lucide-react';
|
||||
import { bookingsApi, seatsApi } from '@/lib/api';
|
||||
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,9 +47,10 @@ 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: stats } = useQuery({
|
||||
queryKey: ['backoffice-stats'],
|
||||
queryFn: dashboardApi.getBackofficeStats,
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
const { data: bookingsData, isLoading } = useQuery({
|
||||
@@ -155,7 +157,7 @@ export default function SeatStatusReportPage() {
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
@@ -200,9 +202,9 @@ export default function SeatStatusReportPage() {
|
||||
<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}
|
||||
{stats?.blockedSeatsCount ?? '—'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Manually blocked</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>
|
||||
|
||||
@@ -16,6 +16,7 @@ interface RouteStop {
|
||||
sequence: number;
|
||||
distanceKm?: number;
|
||||
distanceFromOrigin?: number;
|
||||
checkinMinutesBefore?: number;
|
||||
}
|
||||
|
||||
type Tab = 'routes' | 'coaches';
|
||||
@@ -167,10 +168,13 @@ export default function RoutesPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('routes');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingRoute, setEditingRoute] = useState<any>(null);
|
||||
const [editLoading, setEditLoading] = useState(false);
|
||||
const [stops, setStops] = useState<RouteStop[]>([]);
|
||||
const [originStationId, setOriginStationId] = useState('');
|
||||
const [destinationStationId, setDestinationStationId] = useState('');
|
||||
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
|
||||
const [originCheckinMinutes, setOriginCheckinMinutes] = useState<number | undefined>(undefined);
|
||||
const [destinationCheckinMinutes, setDestinationCheckinMinutes] = useState<number | undefined>(undefined);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null });
|
||||
const [search, setSearch] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
@@ -241,19 +245,22 @@ export default function RoutesPage() {
|
||||
|
||||
// distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm)
|
||||
const stopsArray = [
|
||||
{ stationId: originStationId, sequence: 1, distanceKm: 0 },
|
||||
{ stationId: originStationId, sequence: 1, distanceKm: 0, checkinMinutesBefore: originCheckinMinutes ?? undefined },
|
||||
...sortedMiddleStops.map((stop, idx) => ({
|
||||
stationId: stop.stationId,
|
||||
sequence: idx + 2,
|
||||
distanceKm: stop.distanceFromOrigin || 0,
|
||||
checkinMinutesBefore: stop.checkinMinutesBefore ?? undefined,
|
||||
})),
|
||||
{
|
||||
stationId: destinationStationId,
|
||||
sequence: sortedMiddleStops.length + 2,
|
||||
distanceKm: destinationDistance || 0,
|
||||
checkinMinutesBefore: destinationCheckinMinutes ?? undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const checkinRaw = formData.get('checkinMinutesBefore') as string;
|
||||
const routeData = {
|
||||
code: formData.get('code') as string,
|
||||
name: formData.get('name') as string,
|
||||
@@ -261,6 +268,7 @@ export default function RoutesPage() {
|
||||
active: !editingRoute ? (formData.get('active') !== 'false') : undefined,
|
||||
effectiveFrom: formData.get('effectiveFrom') as string,
|
||||
effectiveUntil: formData.get('effectiveUntil') as string || undefined,
|
||||
checkinMinutesBefore: checkinRaw ? parseInt(checkinRaw) : undefined,
|
||||
stops: stopsArray,
|
||||
};
|
||||
|
||||
@@ -341,6 +349,11 @@ export default function RoutesPage() {
|
||||
{ key: 'code', label: 'Route Code', sortable: true },
|
||||
{ key: 'name', label: 'Route Name', sortable: true },
|
||||
{ key: 'description', label: 'Description', render: (route: any) => route.description || 'N/A' },
|
||||
{
|
||||
key: 'checkinMinutesBefore',
|
||||
label: 'Check-in Cutoff',
|
||||
render: (route: any) => `${route.checkinMinutesBefore ?? 30} min`,
|
||||
},
|
||||
{
|
||||
key: 'active',
|
||||
label: 'Status',
|
||||
@@ -363,29 +376,38 @@ export default function RoutesPage() {
|
||||
);
|
||||
});
|
||||
|
||||
const openEditModal = async (route: any) => {
|
||||
setEditLoading(true);
|
||||
try {
|
||||
const full = await routesApi.getById(route.id) as any;
|
||||
const routeStops: any[] = full?.stops || [];
|
||||
setEditingRoute(full ?? route);
|
||||
if (routeStops.length >= 2) {
|
||||
const originStop = routeStops[0];
|
||||
const destStop = routeStops[routeStops.length - 1];
|
||||
setOriginStationId(originStop.stationId);
|
||||
setOriginCheckinMinutes(originStop.checkinMinutesBefore ?? undefined);
|
||||
setDestinationStationId(destStop.stationId);
|
||||
setDestinationCheckinMinutes(destStop.checkinMinutesBefore ?? undefined);
|
||||
setDestinationDistance(destStop.distanceKm || 0);
|
||||
setStops(routeStops.slice(1, -1).map((s: any) => ({
|
||||
stationId: s.stationId,
|
||||
sequence: s.sequence,
|
||||
distanceKm: s.distanceKm,
|
||||
distanceFromOrigin: s.distanceKm || 0,
|
||||
checkinMinutesBefore: s.checkinMinutesBefore ?? undefined,
|
||||
})));
|
||||
}
|
||||
setShowModal(true);
|
||||
} finally {
|
||||
setEditLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const routeActions = [
|
||||
{
|
||||
label: 'Edit',
|
||||
onClick: (route: any) => {
|
||||
setEditingRoute(route);
|
||||
const routeStops = route.stops || [];
|
||||
if (routeStops.length >= 2) {
|
||||
setOriginStationId(routeStops[0].stationId);
|
||||
setDestinationStationId(routeStops[routeStops.length - 1].stationId);
|
||||
|
||||
// distanceKm is cumulative from origin — read directly
|
||||
setDestinationDistance(routeStops[routeStops.length - 1].distanceKm || 0);
|
||||
|
||||
const middleStops = routeStops.slice(1, -1).map((stop: any) => ({
|
||||
stationId: stop.stationId,
|
||||
sequence: stop.sequence,
|
||||
distanceKm: stop.distanceKm,
|
||||
distanceFromOrigin: stop.distanceKm || 0,
|
||||
}));
|
||||
setStops(middleStops);
|
||||
}
|
||||
setShowModal(true);
|
||||
},
|
||||
onClick: openEditModal,
|
||||
variant: 'secondary' as const,
|
||||
icon: Edit,
|
||||
},
|
||||
@@ -410,10 +432,11 @@ export default function RoutesPage() {
|
||||
onClick={() => {
|
||||
setEditingRoute(null);
|
||||
setOriginStationId('');
|
||||
setOriginCheckinMinutes(undefined);
|
||||
setDestinationStationId('');
|
||||
setDestinationCheckinMinutes(undefined);
|
||||
setDestinationDistance(undefined);
|
||||
setStops([]);
|
||||
setSearch('');
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
@@ -456,7 +479,7 @@ export default function RoutesPage() {
|
||||
data={displayedRoutes}
|
||||
columns={routeColumns}
|
||||
actions={routeActions}
|
||||
loading={routesLoading}
|
||||
loading={routesLoading || editLoading}
|
||||
emptyMessage={search ? 'No routes match your search' : 'No routes found'}
|
||||
/>
|
||||
</>
|
||||
@@ -488,7 +511,9 @@ export default function RoutesPage() {
|
||||
setShowModal(false);
|
||||
setEditingRoute(null);
|
||||
setOriginStationId('');
|
||||
setOriginCheckinMinutes(undefined);
|
||||
setDestinationStationId('');
|
||||
setDestinationCheckinMinutes(undefined);
|
||||
setDestinationDistance(undefined);
|
||||
setStops([]);
|
||||
setSearch('');
|
||||
@@ -588,6 +613,23 @@ export default function RoutesPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Check-in Cutoff (minutes before departure)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="checkinMinutesBefore"
|
||||
className="input"
|
||||
defaultValue={editingRoute?.checkinMinutesBefore ?? 30}
|
||||
min={1}
|
||||
max={480}
|
||||
step={1}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Booking closes and check-in ends this many minutes before each stop's departure. Default: 30.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!editingRoute && (
|
||||
<div>
|
||||
<label className="label">Status</label>
|
||||
@@ -623,7 +665,7 @@ export default function RoutesPage() {
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="label mb-0">Route Stops</label>
|
||||
<span className="text-xs text-muted-foreground">Drag to rearrange intermediate stops</span>
|
||||
<span className="text-xs text-muted-foreground">Drag to rearrange · <span className="font-medium">Cutoff min</span> overrides route check-in window per stop (leave blank to inherit)</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -641,9 +683,18 @@ export default function RoutesPage() {
|
||||
<span className="text-muted-foreground">Select origin station above</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
0 km
|
||||
<div className="w-28 flex-shrink-0">
|
||||
<input
|
||||
type="number"
|
||||
className="input input-sm"
|
||||
placeholder="cutoff min"
|
||||
value={originCheckinMinutes ?? ''}
|
||||
onChange={(e) => setOriginCheckinMinutes(e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
min={1}
|
||||
title="Check-in cutoff override (minutes) for this stop"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground w-12 text-right flex-shrink-0">0 km</div>
|
||||
</div>
|
||||
|
||||
{stops.map((stop, index) => (
|
||||
@@ -678,7 +729,7 @@ export default function RoutesPage() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<div className="w-28">
|
||||
<input
|
||||
type="number"
|
||||
className="input input-sm"
|
||||
@@ -690,6 +741,17 @@ export default function RoutesPage() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="w-28">
|
||||
<input
|
||||
type="number"
|
||||
className="input input-sm"
|
||||
placeholder="cutoff min"
|
||||
value={stop.checkinMinutesBefore ?? ''}
|
||||
onChange={(e) => updateStop(index, 'checkinMinutesBefore', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
min={1}
|
||||
title="Check-in cutoff override (minutes) for this stop"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeStop(index)}
|
||||
@@ -728,7 +790,20 @@ export default function RoutesPage() {
|
||||
<span className="text-muted-foreground">Select destination station above</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<div className="w-28">
|
||||
{destinationStationId && (
|
||||
<input
|
||||
type="number"
|
||||
className="input input-sm"
|
||||
placeholder="cutoff min"
|
||||
value={destinationCheckinMinutes ?? ''}
|
||||
onChange={(e) => setDestinationCheckinMinutes(e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
min={1}
|
||||
title="Check-in cutoff override (minutes) for this stop"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-28">
|
||||
{destinationStationId && (
|
||||
<input
|
||||
type="number"
|
||||
@@ -754,7 +829,9 @@ export default function RoutesPage() {
|
||||
setShowModal(false);
|
||||
setEditingRoute(null);
|
||||
setOriginStationId('');
|
||||
setOriginCheckinMinutes(undefined);
|
||||
setDestinationStationId('');
|
||||
setDestinationCheckinMinutes(undefined);
|
||||
setDestinationDistance(undefined);
|
||||
setStops([]);
|
||||
setSearch('');
|
||||
|
||||
@@ -94,11 +94,11 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Support Email</label>
|
||||
<input type="email" className="input" defaultValue="support@edr-platform.com" />
|
||||
<input type="email" className="input" defaultValue="edr_@edrsc.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Support Phone</label>
|
||||
<input type="tel" className="input" defaultValue="+251911234567" />
|
||||
<input type="tel" className="input" defaultValue="+2519546" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Default Currency</label>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
Banknote,
|
||||
Activity,
|
||||
Smartphone,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -64,7 +65,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||
{ name: 'Passengers', href: '/passengers', icon: Users, permission: PERMS.passengers.view },
|
||||
{ name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view },
|
||||
{ name: 'Boarding', href: '/boarding', icon: LogIn, permission: PERMS.tickets.view },
|
||||
{ name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view },
|
||||
{ name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view },
|
||||
{ name: 'Discrepancy', href: '/discrepancy', icon: Layers, permission: PERMS.seats.manage },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -119,9 +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: 'Passengers', href: '/reports/passengers', icon: Users, 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 },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ export const dashboardApi = {
|
||||
totalNormalTickets: number;
|
||||
totalPackageTickets: number;
|
||||
totalPassengers: number;
|
||||
blockedSeatsCount: number;
|
||||
revenueByCurrency: { currency: string; totalMinor: number }[];
|
||||
packageRevenueByCurrency: { currency: string; totalMinor: number }[];
|
||||
}>('/dashboard/backoffice-stats');
|
||||
|
||||
@@ -160,6 +160,10 @@ export const seatsApi = {
|
||||
undoRemove: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/undo-remove`, {}),
|
||||
setMaintenance: (seatId: string, reason: string) => apiClient.post<any>(`/seats/${seatId}/maintenance`, { reason }),
|
||||
clearMaintenance: (seatId: string) => apiClient.delete(`/seats/${seatId}/maintenance`),
|
||||
getDuplicates: (date: string, scheduleId?: string) =>
|
||||
apiClient.get<any>(`/seats/duplicates?date=${date}${scheduleId ? `&scheduleId=${scheduleId}` : ''}`),
|
||||
resolveDuplicates: (data: { bookingSeatIds: string[]; coachIds: string[] }) =>
|
||||
apiClient.post<any>('/seats/duplicates/resolve', data),
|
||||
};
|
||||
|
||||
// Payments API
|
||||
|
||||
Reference in New Issue
Block a user