Files
ciaovolo/flight-comparator/frontend/src/pages/Dashboard.tsx
domverse 1021664253 feat: implement Phase 2 (component library) + Phase 3 (dashboard)
Phase 2 - New shared components:
- Button: filled/outlined/text/icon variants, loading state
- StatusChip: colored badge with icon per status (completed/running/pending/failed)
- StatCard: icon circle with tinted bg, big number display
- EmptyState: centered icon, title, description, optional action
- SkeletonCard: shimmer loading placeholders (stat, list item, table row)
- SegmentedButton: active shows Check icon, secondary-container colors
- AirportChip: PlaneTakeoff icon, error hover on remove
- Toast: updated to Lucide icons + design token colors

Phase 3 - Dashboard redesign:
- 5 stat cards with skeleton loading
- Status chip separated from destination text (fixes "BDS→DUScompleted" bug)
- Hover lift effect on scan cards
- Relative timestamps (Just now, Xm ago, Today, Yesterday, N days ago)
- EmptyState when no scans exist

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

173 lines
6.4 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 { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
ScanSearch,
Clock,
Loader2,
CheckCircle2,
XCircle,
ArrowRight,
} from 'lucide-react';
import { scanApi } from '../api';
import type { Scan } from '../api';
import StatCard from '../components/StatCard';
import StatusChip from '../components/StatusChip';
import type { ScanStatus } from '../components/StatusChip';
import EmptyState from '../components/EmptyState';
import { SkeletonStatCard, SkeletonListItem } from '../components/SkeletonCard';
interface Stats {
total: number;
pending: number;
running: number;
completed: number;
failed: number;
}
function formatRelativeDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60_000);
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
return date.toLocaleDateString('en-GB', {
day: 'numeric', month: 'short', year: 'numeric',
});
}
export default function Dashboard() {
const navigate = useNavigate();
const [scans, setScans] = useState<Scan[]>([]);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<Stats>({
total: 0, pending: 0, running: 0, completed: 0, failed: 0,
});
useEffect(() => {
loadScans();
}, []);
const loadScans = async () => {
try {
setLoading(true);
const response = await scanApi.list(1, 10);
const scanList = response.data.data;
setScans(scanList);
setStats({
total: response.data.pagination.total,
pending: scanList.filter(s => s.status === 'pending').length,
running: scanList.filter(s => s.status === 'running').length,
completed: scanList.filter(s => s.status === 'completed').length,
failed: scanList.filter(s => s.status === 'failed').length,
});
} catch (error) {
console.error('Failed to load scans:', error);
} finally {
setLoading(false);
}
};
return (
<div className="space-y-6">
{/* ── Stats grid ───────────────────────────────────────────── */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{loading ? (
Array.from({ length: 5 }).map((_, i) => <SkeletonStatCard key={i} />)
) : (
<>
<StatCard label="Total Scans" value={stats.total} icon={ScanSearch} variant="primary" />
<StatCard label="Pending" value={stats.pending} icon={Clock} variant="tertiary" />
<StatCard label="Running" value={stats.running} icon={Loader2} variant="primary" />
<StatCard label="Completed" value={stats.completed} icon={CheckCircle2} variant="secondary" />
<StatCard label="Failed" value={stats.failed} icon={XCircle} variant="error" />
</>
)}
</div>
{/* ── Recent scans ─────────────────────────────────────────── */}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-base font-semibold text-on-surface">Recent Scans</h2>
<Link
to="/scans"
className="text-sm text-primary font-medium hover:underline"
>
+ New scan
</Link>
</div>
{loading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => <SkeletonListItem key={i} />)}
</div>
) : scans.length === 0 ? (
<div className="bg-surface rounded-lg shadow-level-1">
<EmptyState
icon={ScanSearch}
title="No scans yet"
description="Create your first scan to discover flight routes and prices."
action={{ label: '+ New Scan', onClick: () => navigate('/scans') }}
/>
</div>
) : (
<div className="space-y-2">
{scans.map((scan) => (
<Link
key={scan.id}
to={`/scans/${scan.id}`}
className="group block bg-surface rounded-lg px-5 py-4 shadow-level-1 hover:-translate-y-0.5 hover:shadow-level-2 transition-all duration-150"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
{/* Row 1: route + status */}
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-on-surface">
{scan.origin} {scan.country}
</span>
<StatusChip status={scan.status as ScanStatus} />
</div>
{/* Row 2: dates + config */}
<p className="mt-1 text-sm text-on-surface-variant">
{scan.start_date} {scan.end_date}
{' · '}{scan.adults} adult{scan.adults !== 1 ? 's' : ''}
{' · '}{scan.seat_class}
</p>
{/* Row 3: results (only when present) */}
{scan.total_routes > 0 && (
<p className="mt-0.5 text-sm text-on-surface-variant">
{scan.total_routes} routes · {scan.total_flights} flights found
</p>
)}
</div>
{/* Right: date + arrow */}
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-on-surface-variant">
{formatRelativeDate(scan.created_at)}
</span>
<ArrowRight
size={16}
className="text-on-surface-variant group-hover:translate-x-0.5 transition-transform duration-150"
aria-hidden="true"
/>
</div>
</div>
</Link>
))}
</div>
)}
</div>
</div>
);
}