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(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 [flightSortField, setFlightSortField] = useState<'date' | 'price'>('date'); const [flightSortDir, setFlightSortDir] = useState<'asc' | 'desc'>('asc'); const [loadingFlights, setLoadingFlights] = useState(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 ; return sortDirection === 'asc' ? : ; }; const FlightSortIcon = ({ field }: { field: 'date' | 'price' }) => { if (flightSortField !== field) return ; return flightSortDir === '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 && (

)} {/* Row 4: actions */}
{/* ── Active (pending / running): Pause + Cancel ── */} {isActive && ( <> {/* Pause — inline confirm */} {confirmPause ? (
Pause this scan?
) : ( )} {/* Cancel — inline confirm */} {confirmCancel ? (
Cancel this scan?
) : ( )} )} {/* ── Paused: Resume + Re-run + Delete ── */} {scan.status === 'paused' && ( <> {confirmDelete ? (
Delete this scan?
) : ( )} )} {/* ── Completed / Failed / Cancelled: Re-run + Delete ── */} {!isActive && scan.status !== 'paused' && ( <> {confirmDelete ? (
Delete this scan?
) : ( )} )}
{/* ── Stat cards ────────────────────────────────────────────── */}
{loading ? ( [0, 1, 2].map(i => ) ) : ( <> {!isActive && scan.started_at && scan.completed_at && ( )} )}
{/* ── 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

{scan.status === 'running' && scan.started_at && ( )}
)} {/* ── 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 ? (
) : ( {sortedFlights(flightsByDest[route.destination] || []).map((f) => ( ))} {(flightsByDest[route.destination] || []).length === 0 && ( )}
handleFlightSort('date')} > Date Airline Departure Arrival handleFlightSort('price')} > Price
{weekday(f.date)} {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}
)} )}
); }