Files
ciaovolo/flight-comparator/frontend/src/pages/ScanDetails.tsx
domverse d87bbe5148 feat: implement Phase 5 — Scan Details redesign
- Breadcrumb: ← Dashboard / Scan #N with ArrowLeft icon
- Header card: PlaneTakeoff icon + route, StatusChip, metadata row
  (Calendar, Users, Armchair icons), created-at timestamp
- StatCards for Total Routes / Routes Scanned / Flights Found
- Progress card with Loader2 spinner + % bar (running/pending only)
- Routes table: sort indicators, IATA chips (font-mono + primary-container),
  ChevronRight rotates 90° on expand, min price green / avg+max muted
- Animated expand: max-height 0→600px CSS transition (no snap)
- Sub-table light green background (#F8FDF9) with nested indent
- EmptyState for completed 0 routes and failed scans

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 15:00:30 +01:00

447 lines
20 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 } 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<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 [loadingFlights, setLoadingFlights] = useState<string | null>(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 <ChevronUp size={14} className="opacity-30" />;
return sortDirection === 'asc'
? <ChevronUp size={14} className="text-primary" />
: <ChevronDown size={14} className="text-primary" />;
};
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>
</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>
)}
</div>
{/* ── Stat cards ────────────────────────────────────────────── */}
<div className="grid grid-cols-3 gap-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" />
</>
)}
</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>
</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>
</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 text-on-surface-variant">Date</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 text-on-surface-variant">Price</th>
</tr>
</thead>
<tbody className="divide-y divide-[#D4EDDA]">
{(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">{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>
);
}