Files
ciaovolo/flight-comparator/frontend/src/pages/ScanDetails.tsx
domverse 7ece1f9b45
All checks were successful
Deploy / deploy (push) Successful in 35s
fix: replace faint sort arrows with ChevronsUpDown indicator on inactive columns
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 15:39:36 +01:00

789 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Fragment, useEffect, useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import {
ArrowLeft,
PlaneTakeoff,
Calendar,
CalendarClock,
Users,
Armchair,
Clock,
Timer,
ChevronRight,
ChevronUp,
ChevronDown,
ChevronsUpDown,
MapPin,
AlertCircle,
Loader2,
RotateCcw,
Trash2,
Info,
Pause,
Play,
X,
} from 'lucide-react';
import { scanApi } from '../api';
import type { Scan, Route, Flight } from '../api';
import StatusChip from '../components/StatusChip';
import type { ScanStatus } from '../components/StatusChip';
import StatCard from '../components/StatCard';
import EmptyState from '../components/EmptyState';
import { SkeletonStatCard, SkeletonTableRow } from '../components/SkeletonCard';
import ScanTimer, { formatDuration } from '../components/ScanTimer';
import { useScanTimer } from '../hooks/useScanTimer';
import { cn } from '../lib/utils';
const formatPrice = (price?: number) =>
price != null ? `${price.toFixed(2)}` : '—';
const formatDate = (d: string) =>
new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
const WEEKDAYS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
const weekday = (d: string) => WEEKDAYS[new Date(d).getDay()];
export default function ScanDetails() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [scan, setScan] = useState<Scan | null>(null);
const [routes, setRoutes] = useState<Route[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [sortField, setSortField] = useState<'min_price' | 'destination' | 'flight_count'>('min_price');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [expandedRoute, setExpandedRoute] = useState<string | null>(null);
const [flightsByDest, setFlightsByDest] = useState<Record<string, Flight[]>>({});
const [flightSortField, setFlightSortField] = useState<'date' | 'price'>('date');
const [flightSortDir, setFlightSortDir] = useState<'asc' | 'desc'>('asc');
const [loadingFlights, setLoadingFlights] = useState<string | null>(null);
const [rerunning, setRerunning] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
const [confirmPause, setConfirmPause] = useState(false);
const [confirmCancel, setConfirmCancel] = useState(false);
const [stopping, setStopping] = useState(false);
const [resuming, setResuming] = useState(false);
// Must be called unconditionally before any early returns (Rules of Hooks)
const timer = useScanTimer(scan);
useEffect(() => {
if (id) loadScanDetails();
}, [id, page]);
// Auto-refresh while running / pending
useEffect(() => {
if (!scan || (scan.status !== 'pending' && scan.status !== 'running')) return;
const interval = setInterval(() => loadScanDetails(), 3000);
return () => clearInterval(interval);
}, [scan?.status, id]);
// Re-sort when sort params change
useEffect(() => {
const sorted = [...routes].sort((a, b) => {
let aVal: number | string = a[sortField] ?? (sortField === 'min_price' ? Infinity : '');
let bVal: number | string = b[sortField] ?? (sortField === 'min_price' ? Infinity : '');
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
setRoutes(sorted);
}, [sortField, sortDirection]);
const loadScanDetails = async () => {
try {
setLoading(true);
const [scanResp, routesResp] = await Promise.all([
scanApi.get(Number(id)),
scanApi.getRoutes(Number(id), page, 20),
]);
setScan(scanResp.data);
setRoutes(routesResp.data.data);
setTotalPages(routesResp.data.pagination.pages);
} catch (err) {
console.error('Failed to load scan details:', err);
} finally {
setLoading(false);
}
};
const handleSort = (field: typeof sortField) => {
if (sortField === field) {
setSortDirection(d => d === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const handleFlightSort = (field: 'date' | 'price') => {
if (flightSortField === field) {
setFlightSortDir(d => d === 'asc' ? 'desc' : 'asc');
} else {
setFlightSortField(field);
setFlightSortDir('asc');
}
};
const sortedFlights = (flights: Flight[]) =>
[...flights].sort((a, b) => {
const aVal = flightSortField === 'date' ? a.date : (a.price ?? Infinity);
const bVal = flightSortField === 'date' ? b.date : (b.price ?? Infinity);
if (aVal < bVal) return flightSortDir === 'asc' ? -1 : 1;
if (aVal > bVal) return flightSortDir === 'asc' ? 1 : -1;
return 0;
});
const toggleFlights = async (destination: string) => {
if (expandedRoute === destination) { setExpandedRoute(null); return; }
setExpandedRoute(destination);
if (flightsByDest[destination]) return;
setLoadingFlights(destination);
try {
const resp = await scanApi.getFlights(Number(id), destination, 1, 200);
setFlightsByDest(prev => ({ ...prev, [destination]: resp.data.data }));
} catch {
setFlightsByDest(prev => ({ ...prev, [destination]: [] }));
} finally {
setLoadingFlights(null);
}
};
const handleRerun = async () => {
if (!scan) return;
setRerunning(true);
try {
// Compute window from stored dates so the new scan covers the same span
const ms = new Date(scan.end_date).getTime() - new Date(scan.start_date).getTime();
const window_months = Math.max(1, Math.round(ms / (1000 * 60 * 60 * 24 * 30)));
// country column holds either "IT" or "BRI,BDS"
const isAirports = scan.country.includes(',');
const resp = await scanApi.create({
origin: scan.origin,
window_months,
seat_class: scan.seat_class as 'economy' | 'premium' | 'business' | 'first',
adults: scan.adults,
...(isAirports
? { destinations: scan.country.split(',') }
: { country: scan.country }),
});
navigate(`/scans/${resp.data.id}`);
} catch {
// silently fall through — the navigate won't happen
} finally {
setRerunning(false);
}
};
const handleDelete = async () => {
if (!scan) return;
setDeleting(true);
try {
await scanApi.delete(scan.id);
navigate('/');
} catch {
setDeleting(false);
setConfirmDelete(false);
}
};
const handlePause = async () => {
if (!scan) return;
setStopping(true);
try {
await scanApi.pause(scan.id);
await loadScanDetails();
} catch {
// fall through
} finally {
setStopping(false);
setConfirmPause(false);
}
};
const handleCancel = async () => {
if (!scan) return;
setStopping(true);
try {
await scanApi.cancel(scan.id);
await loadScanDetails();
} catch {
// fall through
} finally {
setStopping(false);
setConfirmCancel(false);
}
};
const handleResume = async () => {
if (!scan) return;
setResuming(true);
try {
await scanApi.resume(scan.id);
await loadScanDetails();
} catch {
// fall through
} finally {
setResuming(false);
}
};
const SortIcon = ({ field }: { field: typeof sortField }) => {
if (sortField !== field) return <ChevronsUpDown size={14} className="opacity-50" />;
return sortDirection === 'asc'
? <ChevronUp size={14} className="text-primary" />
: <ChevronDown size={14} className="text-primary" />;
};
const FlightSortIcon = ({ field }: { field: 'date' | 'price' }) => {
if (flightSortField !== field) return <ChevronsUpDown size={12} className="opacity-50" />;
return flightSortDir === 'asc'
? <ChevronUp size={12} className="text-secondary" />
: <ChevronDown size={12} className="text-secondary" />;
};
const thCls = (field?: typeof sortField) => cn(
'px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider select-none',
field
? cn('cursor-pointer hover:bg-surface-2 transition-colors', sortField === field ? 'text-primary' : 'text-on-surface-variant')
: 'text-on-surface-variant',
);
// ── Loading skeleton ────────────────────────────────────────────
if (loading && !scan) {
return (
<div className="space-y-4">
<div className="h-4 w-48 rounded skeleton" />
<div className="bg-surface rounded-lg shadow-level-1 p-6 h-32 skeleton" />
<div className="grid grid-cols-3 gap-3">
{[0, 1, 2].map(i => <SkeletonStatCard key={i} />)}
</div>
</div>
);
}
if (!scan) {
return (
<EmptyState
icon={AlertCircle}
title="Scan not found"
description="This scan doesn't exist or may have been deleted."
action={{ label: '← Dashboard', onClick: () => navigate('/') }}
/>
);
}
const isActive = scan.status === 'pending' || scan.status === 'running';
const progress = scan.total_routes > 0
? Math.min((scan.routes_scanned / scan.total_routes) * 100, 100)
: 0;
return (
<div className="space-y-4">
{/* ── Breadcrumb ────────────────────────────────────────────── */}
<button
onClick={() => navigate('/')}
className="inline-flex items-center gap-1.5 text-sm text-on-surface-variant hover:text-on-surface transition-colors"
>
<ArrowLeft size={16} />
<span>Dashboard</span>
<span className="text-outline">/</span>
<span>Scan #{id}</span>
</button>
{/* ── Header card ───────────────────────────────────────────── */}
<div className="bg-surface rounded-lg shadow-level-1 p-6">
{/* Row 1: route + status chip */}
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap min-w-0">
<PlaneTakeoff size={20} className="text-primary shrink-0" aria-hidden="true" />
<h1 className="text-xl font-semibold text-on-surface">
{scan.origin} {scan.country}
</h1>
{scan.scheduled_scan_id != null && (
<Link
to={`/schedules`}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-primary-container text-on-primary-container hover:opacity-80 transition-opacity"
title={`Scheduled scan #${scan.scheduled_scan_id}`}
>
<CalendarClock size={11} aria-hidden="true" />
Scheduled
</Link>
)}
</div>
<StatusChip status={scan.status as ScanStatus} />
</div>
{/* Row 2: metadata */}
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-sm text-on-surface-variant">
<span className="inline-flex items-center gap-1.5">
<Calendar size={14} aria-hidden="true" />
{formatDate(scan.start_date)} {formatDate(scan.end_date)}
</span>
<span className="inline-flex items-center gap-1.5">
<Users size={14} aria-hidden="true" />
{scan.adults} adult{scan.adults !== 1 ? 's' : ''}
</span>
<span className="inline-flex items-center gap-1.5">
<Armchair size={14} aria-hidden="true" />
{scan.seat_class.charAt(0).toUpperCase() + scan.seat_class.slice(1)}
</span>
</div>
{/* Row 3: created at (completed / failed) */}
{!isActive && (
<p className="mt-2 text-xs text-on-surface-variant inline-flex items-center gap-1.5">
<Clock size={12} aria-hidden="true" />
Created {formatDate(scan.created_at)}
</p>
)}
{/* Row 4: actions */}
<div className="mt-4 pt-4 border-t border-outline flex items-center justify-end gap-2 flex-wrap">
{/* ── Active (pending / running): Pause + Cancel ── */}
{isActive && (
<>
{/* Pause — inline confirm */}
{confirmPause ? (
<div className="inline-flex items-center gap-1.5">
<span className="text-sm text-on-surface-variant">Pause this scan?</span>
<button
onClick={handlePause}
disabled={stopping}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-xs bg-[#7A5200] text-white hover:bg-[#5C3D00] disabled:opacity-60 transition-colors"
>
{stopping ? 'Pausing…' : 'Yes, pause'}
</button>
<button
onClick={() => setConfirmPause(false)}
disabled={stopping}
className="px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 transition-colors"
>
No
</button>
</div>
) : (
<button
onClick={() => setConfirmPause(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 transition-colors"
>
<Pause size={14} />
Pause
</button>
)}
{/* Cancel — inline confirm */}
{confirmCancel ? (
<div className="inline-flex items-center gap-1.5">
<span className="text-sm text-on-surface-variant">Cancel this scan?</span>
<button
onClick={handleCancel}
disabled={stopping}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-xs bg-error text-white hover:bg-error/90 disabled:opacity-60 transition-colors"
>
{stopping ? 'Cancelling…' : 'Yes, cancel'}
</button>
<button
onClick={() => setConfirmCancel(false)}
disabled={stopping}
className="px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 transition-colors"
>
No
</button>
</div>
) : (
<button
onClick={() => setConfirmCancel(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-error/40 text-error hover:bg-error/5 transition-colors"
>
<X size={14} />
Cancel
</button>
)}
</>
)}
{/* ── Paused: Resume + Re-run + Delete ── */}
{scan.status === 'paused' && (
<>
<button
onClick={handleResume}
disabled={resuming}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<Play size={14} className={resuming ? 'animate-pulse' : ''} />
{resuming ? 'Resuming…' : 'Resume'}
</button>
<button
onClick={handleRerun}
disabled={rerunning}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<RotateCcw size={14} className={rerunning ? 'animate-spin' : ''} />
{rerunning ? 'Starting…' : 'Re-run'}
</button>
{confirmDelete ? (
<div className="inline-flex items-center gap-1.5">
<span className="text-sm text-on-surface-variant">Delete this scan?</span>
<button
onClick={handleDelete}
disabled={deleting}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-xs bg-error text-white hover:bg-error/90 disabled:opacity-60 transition-colors"
>
{deleting ? 'Deleting…' : 'Yes, delete'}
</button>
<button
onClick={() => setConfirmDelete(false)}
disabled={deleting}
className="px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-error/40 text-error hover:bg-error/5 transition-colors"
>
<Trash2 size={14} />
Delete
</button>
)}
</>
)}
{/* ── Completed / Failed / Cancelled: Re-run + Delete ── */}
{!isActive && scan.status !== 'paused' && (
<>
<button
onClick={handleRerun}
disabled={rerunning}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<RotateCcw size={14} className={rerunning ? 'animate-spin' : ''} />
{rerunning ? 'Starting…' : 'Re-run'}
</button>
{confirmDelete ? (
<div className="inline-flex items-center gap-1.5">
<span className="text-sm text-on-surface-variant">Delete this scan?</span>
<button
onClick={handleDelete}
disabled={deleting}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-xs bg-error text-white hover:bg-error/90 disabled:opacity-60 transition-colors"
>
{deleting ? 'Deleting…' : 'Yes, delete'}
</button>
<button
onClick={() => setConfirmDelete(false)}
disabled={deleting}
className="px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-error/40 text-error hover:bg-error/5 transition-colors"
>
<Trash2 size={14} />
Delete
</button>
)}
</>
)}
</div>
</div>
{/* ── Stat cards ────────────────────────────────────────────── */}
<div className={`grid gap-3 ${!isActive && scan.started_at && scan.completed_at ? 'grid-cols-4' : 'grid-cols-3'}`}>
{loading ? (
[0, 1, 2].map(i => <SkeletonStatCard key={i} />)
) : (
<>
<StatCard label="Total Routes" value={scan.total_routes} icon={MapPin} variant="primary" />
<StatCard label="Routes Scanned" value={scan.routes_scanned} icon={ChevronDown} variant="secondary" />
<StatCard label="Flights Found" value={scan.total_flights} icon={PlaneTakeoff} variant="primary" />
{!isActive && scan.started_at && scan.completed_at && (
<StatCard
label="Scan Duration"
value={formatDuration(timer.elapsedSeconds)}
icon={Timer}
variant="secondary"
/>
)}
</>
)}
</div>
{/* ── Progress card (running / pending) ─────────────────────── */}
{isActive && (
<div className="bg-surface rounded-lg shadow-level-1 p-5 border border-[#A8C7FA]">
<div className="flex items-center gap-2 mb-3">
<Loader2 size={16} className="text-primary animate-spin shrink-0" aria-hidden="true" />
<span className="text-sm font-medium text-on-surface">
{scan.status === 'pending' ? 'Initializing…' : 'Scanning in progress…'}
</span>
</div>
<div className="flex items-center gap-3">
<div className="flex-1 h-1 bg-surface-2 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-xs text-on-surface-variant shrink-0 w-10 text-right">
{Math.round(progress)}%
</span>
</div>
<p className="mt-2 text-xs text-on-surface-variant">
{scan.routes_scanned} of {scan.total_routes > 0 ? scan.total_routes : '?'} routes · auto-refreshing every 3 s
</p>
{scan.status === 'running' && scan.started_at && (
<ScanTimer {...timer} />
)}
</div>
)}
{/* ── Routes table ──────────────────────────────────────────── */}
<div className="bg-surface rounded-lg shadow-level-1 overflow-hidden">
<div className="px-5 py-4 border-b border-outline">
<h2 className="text-sm font-semibold text-on-surface">Routes Found</h2>
</div>
{routes.length === 0 ? (
<div className="px-6 py-8">
{scan.status === 'completed' ? (
<EmptyState
icon={MapPin}
title="No routes found"
description="No direct flights for the selected airports and date range."
/>
) : scan.status === 'failed' ? (
<EmptyState
icon={AlertCircle}
title="Scan failed"
description={scan.error_message || 'An error occurred during the scan.'}
/>
) : (
/* running/pending — progress card above handles this */
<p className="text-center text-sm text-on-surface-variant py-4">
Routes will appear here as they are discovered
</p>
)}
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-surface-2 border-b border-outline">
<tr>
<th
className={thCls('destination')}
onClick={() => handleSort('destination')}
>
<span className="inline-flex items-center gap-1">
Destination <SortIcon field="destination" />
</span>
</th>
<th
className={thCls('flight_count')}
onClick={() => handleSort('flight_count')}
>
<span className="inline-flex items-center gap-1">
Flights <SortIcon field="flight_count" />
</span>
</th>
<th className={thCls()}>Airlines</th>
<th
className={thCls('min_price')}
onClick={() => handleSort('min_price')}
>
<span className="inline-flex items-center gap-1">
Min Price <SortIcon field="min_price" />
</span>
</th>
<th className={thCls()}>Avg</th>
<th className={thCls()}>Max</th>
</tr>
</thead>
<tbody className="divide-y divide-outline">
{routes.map((route) => {
const isExpanded = expandedRoute === route.destination;
return (
<Fragment key={route.id}>
<tr
className="hover:bg-surface-2 cursor-pointer transition-colors duration-150"
onClick={() => toggleFlights(route.destination)}
>
{/* Destination */}
<td className="px-4 py-4">
<div className="flex items-center gap-2">
<ChevronRight
size={16}
className={cn(
'text-on-surface-variant shrink-0 transition-transform duration-200',
isExpanded && 'rotate-90',
)}
aria-hidden="true"
/>
<span className="font-mono text-primary bg-primary-container px-2 py-0.5 rounded-sm text-sm font-medium">
{route.destination}
</span>
<span className="text-sm text-on-surface-variant truncate max-w-[180px]">
{route.destination_name || route.destination_city || ''}
</span>
{/* Info icon + tooltip — only when useful name data exists */}
{(route.destination_name && route.destination_name !== route.destination) || route.destination_city ? (
<span
className="relative group/tip inline-flex shrink-0"
onClick={e => e.stopPropagation()}
>
<Info size={12} className="text-on-surface-variant/40 hover:text-on-surface-variant/70 cursor-help transition-colors" />
<span className="pointer-events-none absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50 invisible group-hover/tip:visible bg-gray-900 text-white text-xs rounded px-2 py-1 whitespace-nowrap shadow-lg">
{[route.destination_name, route.destination_city].filter(s => s && s !== route.destination).join(', ')}
<span className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-gray-900" />
</span>
</span>
) : null}
</div>
</td>
{/* Flights */}
<td className="px-4 py-4 text-sm text-on-surface">
{route.flight_count}
</td>
{/* Airlines */}
<td className="px-4 py-4 text-sm text-on-surface-variant max-w-[200px]">
<span className="truncate block">{route.airlines.join(', ')}</span>
</td>
{/* Min price */}
<td className="px-4 py-4 text-sm font-medium text-secondary">
{formatPrice(route.min_price)}
</td>
{/* Avg */}
<td className="px-4 py-4 text-sm text-on-surface-variant">
{formatPrice(route.avg_price)}
</td>
{/* Max */}
<td className="px-4 py-4 text-sm text-on-surface-variant">
{formatPrice(route.max_price)}
</td>
</tr>
{/* Expanded flights sub-row */}
<tr key={`${route.id}-flights`}>
<td colSpan={6} className="p-0">
<div
className="overflow-hidden transition-all duration-250 ease-in-out"
style={{ maxHeight: isExpanded ? '600px' : '0' }}
>
<div className="bg-[#F8FDF9]">
{loadingFlights === route.destination ? (
<table className="w-full">
<tbody>
<SkeletonTableRow />
<SkeletonTableRow />
<SkeletonTableRow />
</tbody>
</table>
) : (
<table className="w-full">
<thead className="bg-[#EEF7F0]">
<tr>
<th
className="pl-12 pr-4 py-2 text-left text-xs font-semibold uppercase tracking-wider select-none cursor-pointer hover:bg-[#D4EDDA] transition-colors"
onClick={() => handleFlightSort('date')}
>
<span className={cn('inline-flex items-center gap-1', flightSortField === 'date' ? 'text-secondary' : 'text-on-surface-variant')}>
Date <FlightSortIcon field="date" />
</span>
</th>
<th className="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-on-surface-variant">Airline</th>
<th className="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-on-surface-variant">Departure</th>
<th className="px-4 py-2 text-left text-xs font-semibold uppercase tracking-wider text-on-surface-variant">Arrival</th>
<th
className="px-4 py-2 text-right text-xs font-semibold uppercase tracking-wider select-none cursor-pointer hover:bg-[#D4EDDA] transition-colors"
onClick={() => handleFlightSort('price')}
>
<span className={cn('inline-flex items-center justify-end gap-1', flightSortField === 'price' ? 'text-secondary' : 'text-on-surface-variant')}>
Price <FlightSortIcon field="price" />
</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-[#D4EDDA]">
{sortedFlights(flightsByDest[route.destination] || []).map((f) => (
<tr key={f.id} className="hover:bg-[#EEF7F0] transition-colors">
<td className="pl-12 pr-4 py-2.5 text-sm text-on-surface">
<span className="font-mono text-xs font-semibold text-on-surface-variant mr-2">{weekday(f.date)}</span>
{f.date}
</td>
<td className="px-4 py-2.5 text-sm text-on-surface-variant">{f.airline || '—'}</td>
<td className="px-4 py-2.5 text-sm text-on-surface-variant font-mono">{f.departure_time || '—'}</td>
<td className="px-4 py-2.5 text-sm text-on-surface-variant font-mono">{f.arrival_time || '—'}</td>
<td className="px-4 py-2.5 text-sm font-medium text-secondary text-right">
{f.price != null ? `${f.price.toFixed(2)}` : '—'}
</td>
</tr>
))}
{(flightsByDest[route.destination] || []).length === 0 && (
<tr>
<td colSpan={5} className="pl-12 py-4 text-sm text-on-surface-variant">
No flight details available
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
</div>
</td>
</tr>
</Fragment>
);
})}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="px-5 py-3 border-t border-outline flex items-center justify-between">
<span className="text-sm text-on-surface-variant">
Page {page} of {totalPages}
</span>
<div className="flex gap-2">
<button
onClick={() => setPage(p => p - 1)}
disabled={page === 1}
className="px-3 py-1.5 border border-outline rounded-xs text-sm text-on-surface hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<button
onClick={() => setPage(p => p + 1)}
disabled={page === totalPages}
className="px-3 py-1.5 border border-outline rounded-xs text-sm text-on-surface hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}