All checks were successful
Deploy / deploy (push) Successful in 35s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
789 lines
35 KiB
TypeScript
789 lines
35 KiB
TypeScript
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>
|
||
);
|
||
}
|