mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 17:45:42 +00:00
433 lines
20 KiB
TypeScript
433 lines
20 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
|
import { format } from 'date-fns';
|
|
import {
|
|
Ticket,
|
|
Loader2,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Clock,
|
|
ArrowUpCircle,
|
|
Eye,
|
|
CreditCard,
|
|
RefreshCw,
|
|
AlertCircle,
|
|
} from 'lucide-react';
|
|
import { toZonedDate } from '@/utils/format';
|
|
import { samePhone } from '@/utils/phone';
|
|
import { useAuthStore } from '@/lib/auth-store';
|
|
import {
|
|
fetchMyBookings,
|
|
statusBadge,
|
|
type BookingScope,
|
|
type MyBookingItem,
|
|
} from '@/lib/api/bookings';
|
|
|
|
const PAGE_SIZE = 10;
|
|
|
|
const SCOPES: { id: BookingScope; label: string }[] = [
|
|
{ id: 'upcoming', label: 'Upcoming' },
|
|
{ id: 'past', label: 'Past' },
|
|
{ id: 'cancelled', label: 'Cancelled' },
|
|
];
|
|
|
|
const EMPTY_COPY: Record<BookingScope, string> = {
|
|
upcoming: 'No upcoming trips.',
|
|
past: 'No past trips yet.',
|
|
cancelled: 'No cancelled bookings.',
|
|
all: 'No bookings yet.',
|
|
};
|
|
|
|
function formatTravelDate(iso: string) {
|
|
const d = new Date(iso);
|
|
if (Number.isNaN(d.getTime())) return '—';
|
|
return format(toZonedDate(d), 'dd MMM yyyy, HH:mm');
|
|
}
|
|
|
|
/**
|
|
* "CRS-0002 · 12, 14" for one leg. Round trips carry leg-2 seats on the same booking.
|
|
* Coach.number is already a full code on this data (e.g. "CRS-0002"), so it is printed
|
|
* as-is rather than prefixed.
|
|
*/
|
|
function describeSeats(seats: MyBookingItem['seats'], leg: number) {
|
|
const forLeg = (seats ?? []).filter((s) => (s.leg ?? 1) === leg);
|
|
const numbers = forLeg.map((s) => s.seatNumber).filter(Boolean);
|
|
if (numbers.length === 0) return null;
|
|
const coaches = Array.from(new Set(forLeg.map((s) => s.coachNumber).filter(Boolean)));
|
|
const coachLabel = coaches.length > 0 ? `${coaches.join(' / ')} · ` : '';
|
|
return `${coachLabel}${numbers.join(', ')}`;
|
|
}
|
|
|
|
interface RowActions {
|
|
canReschedule: boolean;
|
|
rescheduleBlocker: string | null;
|
|
canUpgrade: boolean;
|
|
upgradeBlocker: string | null;
|
|
isPendingPayment: boolean;
|
|
}
|
|
|
|
/**
|
|
* The coarse gate for both change actions, mirroring booking/detail/page.tsx. Reschedule and
|
|
* upgrade share it because the booking-shape rules and the ownership check are identical — only
|
|
* the wording differs, hence the verb.
|
|
*
|
|
* The per-leg rules (fare-class policy, cutoffs, whether a higher class even runs on this train,
|
|
* seats still free) belong to the reschedule and upgrade pages, which name them as blockers. This
|
|
* only avoids sending the customer somewhere certain to reject them. The phone test matches the
|
|
* API's own ownership check (loadOwnedBooking), which is phone-based, not account-based.
|
|
*/
|
|
function resolveActions(b: MyBookingItem, userPhone?: string): RowActions {
|
|
const isPendingPayment = b.status === 'PENDING_PAYMENT' || b.status === 'DRAFT';
|
|
|
|
// Both forms are needed: "can be rescheduled" but "can reschedule it".
|
|
type Verbs = { past: string; base: string };
|
|
const RESCHEDULE: Verbs = { past: 'rescheduled', base: 'reschedule' };
|
|
const UPGRADE: Verbs = { past: 'upgraded', base: 'upgrade' };
|
|
|
|
let reason: ((v: Verbs) => string) | null = null;
|
|
if (b.status !== 'CONFIRMED') reason = (v) => `Only a confirmed booking can be ${v.past}`;
|
|
else if (b.isPackageBooking) reason = (v) => `Package bookings cannot be ${v.past} online`;
|
|
else if (!['ONE_WAY', 'ROUND_TRIP'].includes(b.bookingType))
|
|
reason = (v) => `Transit bookings cannot be ${v.past} online`;
|
|
else if (b.outboundBoardedAt) reason = () => 'This trip has already been boarded';
|
|
// Both APIs apply a cutoff measured against departure, so a departed trip is always rejected.
|
|
// Say so here instead of sending them to a page that refuses.
|
|
else if (new Date(b.schedule.departureAt).getTime() <= Date.now())
|
|
reason = () => 'This trip has already departed';
|
|
else if (b.contactPhone && !samePhone(userPhone, b.contactPhone))
|
|
reason = (v) => `Only the person who made this booking can ${v.base} it`;
|
|
|
|
const rescheduleBlocker = reason ? reason(RESCHEDULE) : null;
|
|
const upgradeBlocker = reason ? reason(UPGRADE) : null;
|
|
|
|
return {
|
|
canReschedule: rescheduleBlocker === null,
|
|
rescheduleBlocker,
|
|
canUpgrade: upgradeBlocker === null,
|
|
upgradeBlocker,
|
|
isPendingPayment,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The signed-in customer's own booking history, from GET /bookings/my — no BRN or
|
|
* phone lookup. Rendered by /bookings and by the /profile Bookings tab, so both stay
|
|
* one implementation.
|
|
*/
|
|
export default function MyBookingsTable() {
|
|
const router = useRouter();
|
|
const userPhone = useAuthStore((s) => s.user?.phone);
|
|
const [scope, setScope] = useState<BookingScope>('upcoming');
|
|
const [page, setPage] = useState(1);
|
|
|
|
const { data, isLoading, isError, refetch, isFetching } = useQuery({
|
|
queryKey: ['my-bookings', scope, page],
|
|
queryFn: () => fetchMyBookings({ scope, page, pageSize: PAGE_SIZE }),
|
|
// Keeps the current rows on screen while a tab or page change is in flight instead
|
|
// of collapsing to the spinner and jumping the scroll position.
|
|
placeholderData: keepPreviousData,
|
|
});
|
|
|
|
const items = data?.items ?? [];
|
|
const meta = data?.meta;
|
|
const totalPages = meta?.totalPages ?? 0;
|
|
|
|
const switchScope = (next: BookingScope) => {
|
|
setScope(next);
|
|
setPage(1);
|
|
};
|
|
|
|
const openDetail = (b: MyBookingItem) => router.push(`/booking/detail?ref=${b.bookingRef}`);
|
|
const openReschedule = (b: MyBookingItem) => router.push(`/booking/reschedule?ref=${b.bookingRef}`);
|
|
const openUpgrade = (b: MyBookingItem) => router.push(`/booking/upgrade?ref=${b.bookingRef}`);
|
|
|
|
const cardClass =
|
|
'bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700';
|
|
|
|
return (
|
|
<div>
|
|
{/* Scope tabs */}
|
|
<div className="flex flex-wrap items-center gap-2 mb-5">
|
|
{SCOPES.map(({ id, label }) => (
|
|
<button
|
|
key={id}
|
|
onClick={() => switchScope(id)}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
scope === id
|
|
? 'bg-[rgb(20,113,76)] text-white'
|
|
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700'
|
|
}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
{isFetching && !isLoading && (
|
|
<Loader2 className="w-4 h-4 animate-spin text-gray-400" aria-hidden="true" />
|
|
)}
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className={`${cardClass} py-16 text-center`}>
|
|
<Loader2 className="w-8 h-8 animate-spin text-[rgb(20,113,76)] mx-auto" />
|
|
<p className="text-gray-600 dark:text-gray-400 mt-3 text-sm">Loading your bookings…</p>
|
|
</div>
|
|
) : isError ? (
|
|
<div className={`${cardClass} py-16 text-center`}>
|
|
<AlertCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
|
|
<p className="text-gray-700 dark:text-gray-300 mb-4 text-sm">
|
|
We could not load your bookings just now.
|
|
</p>
|
|
<button
|
|
onClick={() => refetch()}
|
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 text-sm font-medium"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
Try again
|
|
</button>
|
|
</div>
|
|
) : items.length === 0 ? (
|
|
<div className={`${cardClass} py-16 text-center`}>
|
|
<Ticket className="w-14 h-14 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
|
<p className="text-gray-600 dark:text-gray-400 mb-4">{EMPTY_COPY[scope]}</p>
|
|
<button
|
|
onClick={() => router.push('/booking/search')}
|
|
className="px-4 py-2 rounded-lg bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] text-white text-sm font-medium transition-colors"
|
|
>
|
|
Book a trip
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Desktop: the tabular view */}
|
|
<div className={`hidden lg:block ${cardClass} overflow-x-auto`}>
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-200 dark:border-gray-700 text-left text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
|
<th scope="col" className="px-4 py-3 font-semibold">Booking Ref</th>
|
|
<th scope="col" className="px-4 py-3 font-semibold">Travel date & time</th>
|
|
<th scope="col" className="px-4 py-3 font-semibold">Route</th>
|
|
<th scope="col" className="px-4 py-3 font-semibold">Seat / Coach</th>
|
|
<th scope="col" className="px-4 py-3 font-semibold">Status</th>
|
|
<th scope="col" className="px-4 py-3 font-semibold text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((b) => {
|
|
const badge = statusBadge(b.status);
|
|
const actions = resolveActions(b, userPhone);
|
|
const outbound = describeSeats(b.seats, 1);
|
|
const inbound = describeSeats(b.seats, 2);
|
|
return (
|
|
<tr
|
|
key={b.id}
|
|
className="border-b border-gray-100 dark:border-gray-700/60 last:border-0 hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors"
|
|
>
|
|
<td className="px-4 py-3 font-mono font-semibold tracking-wider text-gray-900 dark:text-white whitespace-nowrap">
|
|
{b.bookingRef}
|
|
</td>
|
|
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
|
{formatTravelDate(b.schedule.departureAt)}
|
|
</td>
|
|
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
|
|
{b.schedule.originStation.name} → {b.schedule.destinationStation.name}
|
|
{b.bookingType === 'ROUND_TRIP' && (
|
|
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
|
|
(round trip)
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
|
{outbound ?? <span className="text-gray-400">—</span>}
|
|
{inbound && (
|
|
<span className="block text-xs text-gray-500 dark:text-gray-400">
|
|
Return: {inbound}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 whitespace-nowrap">
|
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${badge.className}`}>
|
|
{badge.label}
|
|
</span>
|
|
{b.rescheduled && (
|
|
<span className="ml-1.5 text-xs px-2 py-0.5 rounded-full font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300">
|
|
Rescheduled
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center justify-end gap-2">
|
|
<button
|
|
onClick={() => openDetail(b)}
|
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 text-xs font-medium transition-colors whitespace-nowrap"
|
|
>
|
|
{actions.isPendingPayment ? (
|
|
<>
|
|
<CreditCard className="w-3.5 h-3.5" />
|
|
Complete payment
|
|
</>
|
|
) : (
|
|
<>
|
|
<Eye className="w-3.5 h-3.5" />
|
|
View ticket
|
|
</>
|
|
)}
|
|
</button>
|
|
{!actions.isPendingPayment && (
|
|
<button
|
|
onClick={() => openReschedule(b)}
|
|
disabled={!actions.canReschedule}
|
|
title={
|
|
actions.rescheduleBlocker ??
|
|
'Change the date, train or seats on this booking'
|
|
}
|
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed text-xs font-medium transition-colors whitespace-nowrap"
|
|
>
|
|
<Clock className="w-3.5 h-3.5" />
|
|
Reschedule
|
|
</button>
|
|
)}
|
|
{!actions.isPendingPayment && (
|
|
<button
|
|
onClick={() => openUpgrade(b)}
|
|
disabled={!actions.canUpgrade}
|
|
title={
|
|
actions.upgradeBlocker ??
|
|
'Move to a higher fare class on the same train'
|
|
}
|
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed text-xs font-medium transition-colors whitespace-nowrap"
|
|
>
|
|
<ArrowUpCircle className="w-3.5 h-3.5" />
|
|
Upgrade
|
|
</button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Mobile: the same rows as cards — the portal's established list pattern */}
|
|
<div className="lg:hidden space-y-3">
|
|
{items.map((b) => {
|
|
const badge = statusBadge(b.status);
|
|
const actions = resolveActions(b, userPhone);
|
|
const outbound = describeSeats(b.seats, 1);
|
|
const inbound = describeSeats(b.seats, 2);
|
|
return (
|
|
<div key={b.id} className={`${cardClass} p-4`}>
|
|
<div className="flex items-start justify-between gap-2 mb-2">
|
|
<span className="font-mono font-bold tracking-wider text-gray-900 dark:text-white">
|
|
{b.bookingRef}
|
|
</span>
|
|
<div className="flex flex-wrap justify-end gap-1">
|
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${badge.className}`}>
|
|
{badge.label}
|
|
</span>
|
|
{b.rescheduled && (
|
|
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-300">
|
|
Rescheduled
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-sm text-gray-700 dark:text-gray-300">
|
|
{b.schedule.originStation.name} → {b.schedule.destinationStation.name}
|
|
</p>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
{formatTravelDate(b.schedule.departureAt)}
|
|
</p>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
Seat / Coach: {outbound ?? '—'}
|
|
{inbound && ` · Return: ${inbound}`}
|
|
</p>
|
|
|
|
<div className="flex flex-wrap gap-2 mt-3">
|
|
<button
|
|
onClick={() => openDetail(b)}
|
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 text-xs font-medium"
|
|
>
|
|
{actions.isPendingPayment ? (
|
|
<>
|
|
<CreditCard className="w-3.5 h-3.5" />
|
|
Complete payment
|
|
</>
|
|
) : (
|
|
<>
|
|
<Eye className="w-3.5 h-3.5" />
|
|
View ticket
|
|
</>
|
|
)}
|
|
</button>
|
|
{!actions.isPendingPayment && (
|
|
<button
|
|
onClick={() => openReschedule(b)}
|
|
disabled={!actions.canReschedule}
|
|
title={
|
|
actions.rescheduleBlocker ??
|
|
'Change the date, train or seats on this booking'
|
|
}
|
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 disabled:opacity-40 disabled:cursor-not-allowed text-xs font-medium"
|
|
>
|
|
<Clock className="w-3.5 h-3.5" />
|
|
Reschedule
|
|
</button>
|
|
)}
|
|
{!actions.isPendingPayment && (
|
|
<button
|
|
onClick={() => openUpgrade(b)}
|
|
disabled={!actions.canUpgrade}
|
|
title={
|
|
actions.upgradeBlocker ??
|
|
'Move to a higher fare class on the same train'
|
|
}
|
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 disabled:opacity-40 disabled:cursor-not-allowed text-xs font-medium"
|
|
>
|
|
<ArrowUpCircle className="w-3.5 h-3.5" />
|
|
Upgrade
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-between gap-3 mt-4">
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
Page {meta?.page ?? page} of {totalPages} · {meta?.total ?? items.length} booking
|
|
{(meta?.total ?? items.length) !== 1 ? 's' : ''}
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
disabled={page <= 1}
|
|
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed text-xs font-medium"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
Previous
|
|
</button>
|
|
<button
|
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
disabled={page >= totalPages}
|
|
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed text-xs font-medium"
|
|
>
|
|
Next
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|