import { Fragment, useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { ArrowLeft, PlaneTakeoff, Calendar, Users, Armchair, Clock, ChevronRight, ChevronUp, ChevronDown, MapPin, AlertCircle, Loader2, } 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 { 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' }); export default function ScanDetails() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [scan, setScan] = useState(null); const [routes, setRoutes] = useState([]); 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(null); const [flightsByDest, setFlightsByDest] = useState>({}); const [loadingFlights, setLoadingFlights] = useState(null); 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 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 SortIcon = ({ field }: { field: typeof sortField }) => { if (sortField !== field) return ; return sortDirection === 'asc' ? : ; }; 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 (
{[0, 1, 2].map(i => )}
); } if (!scan) { return ( 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 (
{/* ── Breadcrumb ────────────────────────────────────────────── */} {/* ── Header card ───────────────────────────────────────────── */}
{/* Row 1: route + status chip */}
{/* Row 2: metadata */}
{/* Row 3: created at (completed / failed) */} {!isActive && (

)}
{/* ── Stat cards ────────────────────────────────────────────── */}
{loading ? ( [0, 1, 2].map(i => ) ) : ( <> )}
{/* ── Progress card (running / pending) ─────────────────────── */} {isActive && (
{Math.round(progress)}%

{scan.routes_scanned} of {scan.total_routes > 0 ? scan.total_routes : '?'} routes · auto-refreshing every 3 s

)} {/* ── Routes table ──────────────────────────────────────────── */}

Routes Found

{routes.length === 0 ? (
{scan.status === 'completed' ? ( ) : scan.status === 'failed' ? ( ) : ( /* running/pending — progress card above handles this */

Routes will appear here as they are discovered…

)}
) : ( <>
{routes.map((route) => { const isExpanded = expandedRoute === route.destination; return ( toggleFlights(route.destination)} > {/* Destination */} {/* Flights */} {/* Airlines */} {/* Min price */} {/* Avg */} {/* Max */} {/* Expanded flights sub-row */} ); })}
handleSort('destination')} > Destination handleSort('flight_count')} > Flights Airlines handleSort('min_price')} > Min Price Avg Max
{route.flight_count} {route.airlines.join(', ')} {formatPrice(route.min_price)} {formatPrice(route.avg_price)} {formatPrice(route.max_price)}
{loadingFlights === route.destination ? (
) : ( {(flightsByDest[route.destination] || []).map((f) => ( ))} {(flightsByDest[route.destination] || []).length === 0 && ( )}
Date Airline Departure Arrival Price
{f.date} {f.airline || '—'} {f.departure_time || '—'} {f.arrival_time || '—'} {f.price != null ? `€${f.price.toFixed(2)}` : '—'}
No flight details available
)}
{/* Pagination */} {totalPages > 1 && (
Page {page} of {totalPages}
)} )}
); }