From d87bbe514898813136b1bb78e3128cbe678f1ad5 Mon Sep 17 00:00:00 2001 From: domverse Date: Fri, 27 Feb 2026 15:00:30 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20Phase=205=20=E2=80=94=20Sca?= =?UTF-8?q?n=20Details=20redesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../frontend/src/pages/ScanDetails.tsx | 544 ++++++++++-------- 1 file changed, 303 insertions(+), 241 deletions(-) diff --git a/flight-comparator/frontend/src/pages/ScanDetails.tsx b/flight-comparator/frontend/src/pages/ScanDetails.tsx index e263740..d5ca213 100644 --- a/flight-comparator/frontend/src/pages/ScanDetails.tsx +++ b/flight-comparator/frontend/src/pages/ScanDetails.tsx @@ -1,52 +1,64 @@ 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 [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(); - } + if (id) loadScanDetails(); }, [id, page]); - // Auto-refresh while scan is running + // Auto-refresh while running / pending useEffect(() => { - if (!scan || (scan.status !== 'pending' && scan.status !== 'running')) { - return; - } - - const interval = setInterval(() => { - loadScanDetails(); - }, 3000); // Poll every 3 seconds - + 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(() => { - // Sort routes when sort field or direction changes const sorted = [...routes].sort((a, b) => { - let aVal: any = a[sortField]; - let bVal: any = b[sortField]; - - if (sortField === 'min_price') { - aVal = aVal ?? Infinity; - bVal = bVal ?? Infinity; - } - + 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; @@ -57,16 +69,15 @@ export default function ScanDetails() { const loadScanDetails = async () => { try { setLoading(true); - const [scanResponse, routesResponse] = await Promise.all([ + const [scanResp, routesResp] = await Promise.all([ scanApi.get(Number(id)), scanApi.getRoutes(Number(id), page, 20), ]); - - setScan(scanResponse.data); - setRoutes(routesResponse.data.data); - setTotalPages(routesResponse.data.pagination.pages); - } catch (error) { - console.error('Failed to load scan details:', error); + 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); } @@ -74,7 +85,7 @@ export default function ScanDetails() { const handleSort = (field: typeof sortField) => { if (sortField === field) { - setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + setSortDirection(d => d === 'asc' ? 'desc' : 'asc'); } else { setSortField(field); setSortDirection('asc'); @@ -82,294 +93,344 @@ export default function ScanDetails() { }; const toggleFlights = async (destination: string) => { - if (expandedRoute === destination) { - setExpandedRoute(null); - return; - } + if (expandedRoute === destination) { setExpandedRoute(null); return; } setExpandedRoute(destination); - if (flightsByDest[destination]) return; // already loaded + if (flightsByDest[destination]) return; setLoadingFlights(destination); try { const resp = await scanApi.getFlights(Number(id), destination, 1, 200); - setFlightsByDest((prev) => ({ ...prev, [destination]: resp.data.data })); + setFlightsByDest(prev => ({ ...prev, [destination]: resp.data.data })); } catch { - setFlightsByDest((prev) => ({ ...prev, [destination]: [] })); + setFlightsByDest(prev => ({ ...prev, [destination]: [] })); } finally { setLoadingFlights(null); } }; - const getStatusColor = (status: string) => { - switch (status) { - case 'completed': return 'bg-green-100 text-green-800'; - case 'running': return 'bg-blue-100 text-blue-800'; - case 'pending': return 'bg-yellow-100 text-yellow-800'; - case 'failed': return 'bg-red-100 text-red-800'; - default: return 'bg-gray-100 text-gray-800'; - } + const SortIcon = ({ field }: { field: typeof sortField }) => { + if (sortField !== field) return ; + return sortDirection === 'asc' + ? + : ; }; - const formatPrice = (price?: number) => { - return price ? `€${price.toFixed(2)}` : 'N/A'; - }; + 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 ( -
-
Loading...
+
+
+
+
+ {[0, 1, 2].map(i => )} +
); } if (!scan) { return ( -
-

Scan not found

-
+ 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 ( -
- {/* Header */} -
- -
-
-

+
+ + {/* ── Breadcrumb ────────────────────────────────────────────── */} + + + {/* ── Header card ───────────────────────────────────────────── */} +
+ {/* Row 1: route + status chip */} +
+
+

- - {scan.status} + +
+ + {/* Row 2: metadata */} +
+ + + + + +
+ + {/* Row 3: created at (completed / failed) */} + {!isActive && ( +

+

+ )}
- {/* Progress Bar (for running scans) */} - {(scan.status === 'pending' || scan.status === 'running') && ( -
-
- - {scan.status === 'pending' ? 'Initializing...' : 'Scanning in progress...'} - - - {scan.routes_scanned} / {scan.total_routes > 0 ? scan.total_routes : '?'} routes + {/* ── Stat cards ────────────────────────────────────────────── */} +
+ {loading ? ( + [0, 1, 2].map(i => ) + ) : ( + <> + + + + + )} +
+ + {/* ── Progress card (running / pending) ─────────────────────── */} + {isActive && ( +
+
+
-
-
0 - ? `${Math.min((scan.routes_scanned / scan.total_routes) * 100, 100)}%` - : '0%' - }} - >
+
+
+
+
+ + {Math.round(progress)}% +
-

- Auto-refreshing every 3 seconds... +

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

)} - {/* Stats */} -
-
-
Total Routes
-
{scan.total_routes}
-
-
-
Routes Scanned
-
{scan.routes_scanned}
-
-
-
Total Flights
-
{scan.total_flights}
-
-
- - {/* Routes Table */} -
-
-

Routes Found

+ {/* ── Routes table ──────────────────────────────────────────── */} +
+
+

Routes Found

{routes.length === 0 ? ( -
+
{scan.status === 'completed' ? ( -
-

No routes found

-

No flights available for the selected route and dates.

-
+ ) : scan.status === 'failed' ? ( -
-

Scan failed

- {scan.error_message && ( -

{scan.error_message}

- )} -
+ ) : ( -
-
-

Scanning in progress...

-

- Routes will appear here as they are discovered. -

-
+ /* running/pending — progress card above handles this */ +

+ Routes will appear here as they are discovered… +

)}
) : ( <>
- + - - + - - + + - - {routes.map((route) => ( - - toggleFlights(route.destination)} - > - + {routes.map((route) => { + const isExpanded = expandedRoute === route.destination; + return ( + + toggleFlights(route.destination)} + > + {/* Destination */} + - - - - - - - - {expandedRoute === route.destination && ( - - + {/* Flights */} + + {/* Airlines */} + + {/* Min price */} + + {/* Avg */} + + {/* Max */} + - )} - - ))} + + {/* Expanded flights sub-row */} + + + + + ); + })}
handleSort('destination')} > - Destination {sortField === 'destination' && (sortDirection === 'asc' ? '↑' : '↓')} - - City + + Destination + handleSort('flight_count')} > - Flights {sortField === 'flight_count' && (sortDirection === 'asc' ? '↑' : '↓')} - - Airlines + + Flights + Airlines handleSort('min_price')} > - Min Price {sortField === 'min_price' && (sortDirection === 'asc' ? '↑' : '↓')} - - Avg Price - - Max Price + + Min Price + AvgMax
-
- - {expandedRoute === route.destination ? '▼' : '▶'} - -
-
{route.destination}
-
{route.destination_name}
+
+
+
- -
- {route.destination_city || 'N/A'} - - {route.flight_count} - -
- {route.airlines.join(', ')} -
-
- {formatPrice(route.min_price)} - - {formatPrice(route.avg_price)} - - {formatPrice(route.max_price)} -
- {loadingFlights === route.destination ? ( -
Loading flights...
- ) : ( - - - - - - - - - - - - {(flightsByDest[route.destination] || []).map((f) => ( - - - - - - - - ))} - {(flightsByDest[route.destination] || []).length === 0 && ( - - - - )} - -
DateAirlineDepartureArrivalPrice
{f.date}{f.airline || '—'}{f.departure_time || '—'}{f.arrival_time || '—'} - {f.price != null ? `€${f.price.toFixed(2)}` : '—'} -
- No flight details available -
- )} +
+ {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 && ( + + + + )} + +
DateAirlineDepartureArrivalPrice
{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} -
-
+ +
@@ -379,6 +440,7 @@ export default function ScanDetails() { )}
+
); }