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>
This commit is contained in:
27
flight-comparator/frontend/src/components/AirportChip.tsx
Normal file
27
flight-comparator/frontend/src/components/AirportChip.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { PlaneTakeoff, X } from 'lucide-react';
|
||||
|
||||
interface AirportChipProps {
|
||||
code: string;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export default function AirportChip({ code, onRemove }: AirportChipProps) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 h-8 pl-3 pr-1.5 border border-outline rounded-lg bg-surface-2 text-sm font-medium text-on-surface transition-shadow hover:shadow-level-1">
|
||||
<PlaneTakeoff
|
||||
size={14}
|
||||
className="text-on-surface-variant shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{code}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="ml-0.5 w-5 h-5 flex items-center justify-center rounded-full text-on-surface-variant hover:bg-error-container hover:text-error transition-colors"
|
||||
aria-label={`Remove ${code}`}
|
||||
>
|
||||
<X size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
63
flight-comparator/frontend/src/components/Button.tsx
Normal file
63
flight-comparator/frontend/src/components/Button.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'filled' | 'outlined' | 'text' | 'icon';
|
||||
size?: 'sm' | 'md';
|
||||
loading?: boolean;
|
||||
icon?: LucideIcon;
|
||||
iconPosition?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export default function Button({
|
||||
variant = 'filled',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
icon: Icon,
|
||||
iconPosition = 'left',
|
||||
disabled,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const isDisabled = disabled || loading;
|
||||
const iconSize = size === 'sm' ? 14 : 16;
|
||||
|
||||
const base =
|
||||
'inline-flex items-center justify-center gap-1.5 font-medium transition-all duration-150 ' +
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 ' +
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed select-none';
|
||||
|
||||
const variants: Record<NonNullable<ButtonProps['variant']>, string> = {
|
||||
filled: 'h-9 px-4 rounded-full bg-primary text-on-primary text-sm hover:opacity-90 shadow-sm',
|
||||
outlined: 'h-9 px-4 rounded-full border border-outline text-primary text-sm hover:bg-primary-container',
|
||||
text: 'h-9 px-3 rounded-full text-primary text-sm hover:bg-surface-2',
|
||||
icon: 'w-10 h-10 rounded-full text-on-surface-variant hover:bg-surface-2',
|
||||
};
|
||||
|
||||
const smOverrides: Partial<Record<NonNullable<ButtonProps['variant']>, string>> = {
|
||||
filled: 'h-8 px-3 text-xs',
|
||||
outlined: 'h-8 px-3 text-xs',
|
||||
text: 'h-8 px-2 text-xs',
|
||||
icon: 'w-8 h-8',
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
disabled={isDisabled}
|
||||
className={cn(base, variants[variant], size === 'sm' && smOverrides[variant], className)}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={iconSize} className="animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<>
|
||||
{Icon && iconPosition === 'left' && <Icon size={iconSize} aria-hidden="true" />}
|
||||
{children}
|
||||
{Icon && iconPosition === 'right' && <Icon size={iconSize} aria-hidden="true" />}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
35
flight-comparator/frontend/src/components/EmptyState.tsx
Normal file
35
flight-comparator/frontend/src/components/EmptyState.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import Button from './Button';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: { label: string; onClick: () => void };
|
||||
}
|
||||
|
||||
export default function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center px-6">
|
||||
<div className="w-24 h-24 rounded-full bg-surface-2 flex items-center justify-center mb-6">
|
||||
<Icon size={40} className="text-on-surface-variant opacity-40" aria-hidden="true" />
|
||||
</div>
|
||||
<h3 className="text-xl font-medium text-on-surface mb-2">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-sm text-on-surface-variant max-w-sm mb-6 leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{action && (
|
||||
<Button variant="filled" onClick={action.onClick}>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Check } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
interface SegmentedButtonProps {
|
||||
options: Option[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SegmentedButton({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: SegmentedButtonProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex border border-outline rounded-full overflow-hidden w-full',
|
||||
className,
|
||||
)}
|
||||
role="group"
|
||||
>
|
||||
{options.map((option, i) => {
|
||||
const active = option.value === value;
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 h-10 px-4 text-sm font-medium transition-colors duration-150',
|
||||
i > 0 && 'border-l border-outline',
|
||||
active
|
||||
? 'bg-secondary-container text-on-secondary-container'
|
||||
: 'bg-transparent text-on-surface hover:bg-surface-2',
|
||||
)}
|
||||
>
|
||||
{active ? (
|
||||
<Check size={16} aria-hidden="true" />
|
||||
) : (
|
||||
Icon && <Icon size={16} aria-hidden="true" />
|
||||
)}
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
flight-comparator/frontend/src/components/SkeletonCard.tsx
Normal file
61
flight-comparator/frontend/src/components/SkeletonCard.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/** Shimmer skeleton building blocks — import named exports as needed. */
|
||||
|
||||
function Bone({ className }: { className?: string }) {
|
||||
return <div className={`skeleton rounded ${className ?? ''}`} />;
|
||||
}
|
||||
|
||||
/** Matches StatCard dimensions */
|
||||
export function SkeletonStatCard() {
|
||||
return (
|
||||
<div className="bg-surface rounded-lg p-5 shadow-level-1 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bone className="w-10 h-10 rounded-full shrink-0" />
|
||||
<Bone className="h-4 w-24" />
|
||||
</div>
|
||||
<Bone className="h-9 w-16" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Matches a scan list-card row */
|
||||
export function SkeletonListItem() {
|
||||
return (
|
||||
<div className="bg-surface rounded-lg px-5 py-4 shadow-level-1 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Bone className="h-5 w-40" />
|
||||
<Bone className="h-5 w-20 rounded-full" />
|
||||
</div>
|
||||
<Bone className="h-4 w-64" />
|
||||
<Bone className="h-4 w-36" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Matches a data-table row */
|
||||
export function SkeletonTableRow() {
|
||||
return (
|
||||
<tr className="border-b border-outline-variant">
|
||||
<td className="px-4 py-4">
|
||||
<Bone className="h-4 w-20" />
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<Bone className="h-4 w-32" />
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<Bone className="h-4 w-12" />
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<Bone className="h-4 w-24" />
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<Bone className="h-4 w-16" />
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<Bone className="h-4 w-16" />
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<Bone className="h-4 w-16" />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
54
flight-comparator/frontend/src/components/StatCard.tsx
Normal file
54
flight-comparator/frontend/src/components/StatCard.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
type Variant = 'default' | 'primary' | 'secondary' | 'tertiary' | 'error';
|
||||
|
||||
const ICON_STYLES: Record<Variant, { bg: string; icon: string }> = {
|
||||
default: { bg: 'bg-[#E8F0FE]', icon: 'text-primary' },
|
||||
primary: { bg: 'bg-[#E8F0FE]', icon: 'text-primary' },
|
||||
secondary: { bg: 'bg-[#E6F4EA]', icon: 'text-secondary' },
|
||||
tertiary: { bg: 'bg-[#FEF7E0]', icon: 'text-tertiary' },
|
||||
error: { bg: 'bg-[#FDECEA]', icon: 'text-error' },
|
||||
};
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number | string;
|
||||
icon: LucideIcon;
|
||||
variant?: Variant;
|
||||
trend?: string;
|
||||
}
|
||||
|
||||
export default function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
variant = 'default',
|
||||
trend,
|
||||
}: StatCardProps) {
|
||||
const styles = ICON_STYLES[variant];
|
||||
|
||||
return (
|
||||
<div className="bg-surface rounded-lg p-5 shadow-level-1 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-full flex items-center justify-center shrink-0',
|
||||
styles.bg,
|
||||
)}
|
||||
>
|
||||
<Icon size={20} className={styles.icon} aria-hidden="true" />
|
||||
</div>
|
||||
<span className="text-sm text-on-surface-variant font-medium leading-tight">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-on-surface tabular-nums leading-none">
|
||||
{value}
|
||||
</p>
|
||||
{trend && (
|
||||
<p className="text-xs text-on-surface-variant">{trend}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
flight-comparator/frontend/src/components/StatusChip.tsx
Normal file
66
flight-comparator/frontend/src/components/StatusChip.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { CheckCircle2, Loader2, Clock, XCircle } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export type ScanStatus = 'completed' | 'running' | 'pending' | 'failed';
|
||||
|
||||
interface StatusConfig {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
chipClass: string;
|
||||
iconClass: string;
|
||||
spin?: boolean;
|
||||
}
|
||||
|
||||
const CONFIGS: Record<ScanStatus, StatusConfig> = {
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
label: 'completed',
|
||||
chipClass: 'bg-[#E6F4EA] text-[#137333] border border-[#A8D5B5]',
|
||||
iconClass: 'text-[#137333]',
|
||||
},
|
||||
running: {
|
||||
icon: Loader2,
|
||||
label: 'running',
|
||||
chipClass: 'bg-[#E8F0FE] text-[#1557B0] border border-[#A8C7FA]',
|
||||
iconClass: 'text-[#1557B0]',
|
||||
spin: true,
|
||||
},
|
||||
pending: {
|
||||
icon: Clock,
|
||||
label: 'pending',
|
||||
chipClass: 'bg-[#FEF7E0] text-[#7A5200] border border-[#F9D659]',
|
||||
iconClass: 'text-[#7A5200]',
|
||||
},
|
||||
failed: {
|
||||
icon: XCircle,
|
||||
label: 'failed',
|
||||
chipClass: 'bg-[#FDECEA] text-[#A50E0E] border border-[#F5C6C6]',
|
||||
iconClass: 'text-[#A50E0E]',
|
||||
},
|
||||
};
|
||||
|
||||
interface StatusChipProps {
|
||||
status: ScanStatus;
|
||||
}
|
||||
|
||||
export default function StatusChip({ status }: StatusChipProps) {
|
||||
const cfg = CONFIGS[status] ?? CONFIGS.failed;
|
||||
const { icon: Icon, label, chipClass, iconClass, spin } = cfg;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium shrink-0',
|
||||
chipClass,
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
size={12}
|
||||
className={cn(iconClass, spin && 'animate-spin')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { CheckCircle2, XCircle, AlertTriangle, Info, X } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
@@ -9,88 +11,61 @@ interface ToastProps {
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export default function Toast({ message, type, onClose, duration = 5000 }: ToastProps) {
|
||||
const CONFIGS = {
|
||||
success: {
|
||||
icon: CheckCircle2,
|
||||
className: 'bg-[#E6F4EA] border border-[#A8D5B5] text-[#137333]',
|
||||
iconClass: 'text-[#137333]',
|
||||
},
|
||||
error: {
|
||||
icon: XCircle,
|
||||
className: 'bg-[#FDECEA] border border-[#F5C6C6] text-[#A50E0E]',
|
||||
iconClass: 'text-[#A50E0E]',
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
className: 'bg-[#FEF7E0] border border-[#F9D659] text-[#7A5200]',
|
||||
iconClass: 'text-[#7A5200]',
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
className: 'bg-[#E8F0FE] border border-[#A8C7FA] text-[#1557B0]',
|
||||
iconClass: 'text-[#1557B0]',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default function Toast({
|
||||
message,
|
||||
type,
|
||||
onClose,
|
||||
duration = 5000,
|
||||
}: ToastProps) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onClose, duration);
|
||||
return () => clearTimeout(timer);
|
||||
}, [duration, onClose]);
|
||||
|
||||
const getColors = () => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'bg-green-50 border-green-200 text-green-800';
|
||||
case 'error':
|
||||
return 'bg-red-50 border-red-200 text-red-800';
|
||||
case 'warning':
|
||||
return 'bg-yellow-50 border-yellow-200 text-yellow-800';
|
||||
case 'info':
|
||||
return 'bg-blue-50 border-blue-200 text-blue-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return (
|
||||
<svg className="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<svg className="w-5 h-5 text-red-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'warning':
|
||||
return (
|
||||
<svg className="w-5 h-5 text-yellow-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'info':
|
||||
return (
|
||||
<svg className="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
const { icon: Icon, className, iconClass } = CONFIGS[type];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-4 right-4 flex items-center p-4 border rounded-lg shadow-lg ${getColors()} animate-slide-up`}
|
||||
style={{ minWidth: '300px', maxWidth: '500px' }}
|
||||
className={cn(
|
||||
'fixed right-4 bottom-24 lg:bottom-6 z-50',
|
||||
'flex items-start gap-3 p-4 rounded-xl shadow-level-3 animate-slide-up',
|
||||
'w-[calc(100vw-2rem)] sm:w-auto sm:min-w-72 sm:max-w-sm',
|
||||
className,
|
||||
)}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="flex-shrink-0">{getIcon()}</div>
|
||||
<p className="ml-3 text-sm font-medium flex-1">{message}</p>
|
||||
<Icon size={18} className={cn('shrink-0 mt-0.5', iconClass)} aria-hidden="true" />
|
||||
<p className="flex-1 text-sm font-medium leading-snug">{message}</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="ml-4 flex-shrink-0 text-gray-400 hover:text-gray-600"
|
||||
className="shrink-0 -mt-0.5 -mr-0.5 w-7 h-7 flex items-center justify-center rounded-full hover:bg-black/10 transition-colors"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,52 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
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 [scans, setScans] = useState<Scan[]>([]);
|
||||
const navigate = useNavigate();
|
||||
const [scans, setScans] = useState<Scan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stats, setStats] = useState({
|
||||
total: 0,
|
||||
pending: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
const [stats, setStats] = useState<Stats>({
|
||||
total: 0, pending: 0, running: 0, completed: 0, failed: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -24,14 +59,12 @@ export default function Dashboard() {
|
||||
const response = await scanApi.list(1, 10);
|
||||
const scanList = response.data.data;
|
||||
setScans(scanList);
|
||||
|
||||
// Calculate stats
|
||||
setStats({
|
||||
total: response.data.pagination.total,
|
||||
pending: scanList.filter(s => s.status === 'pending').length,
|
||||
running: scanList.filter(s => s.status === 'running').length,
|
||||
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,
|
||||
failed: scanList.filter(s => s.status === 'failed').length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load scans:', error);
|
||||
@@ -40,118 +73,100 @@ export default function Dashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
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 formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="text-gray-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Dashboard</h2>
|
||||
<Link
|
||||
to="/scans"
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
+ New Scan
|
||||
</Link>
|
||||
<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>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 mb-8">
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<div className="text-sm font-medium text-gray-500">Total Scans</div>
|
||||
<div className="text-3xl font-bold text-gray-900 mt-2">{stats.total}</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>
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<div className="text-sm font-medium text-gray-500">Pending</div>
|
||||
<div className="text-3xl font-bold text-yellow-600 mt-2">{stats.pending}</div>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<div className="text-sm font-medium text-gray-500">Running</div>
|
||||
<div className="text-3xl font-bold text-blue-600 mt-2">{stats.running}</div>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<div className="text-sm font-medium text-gray-500">Completed</div>
|
||||
<div className="text-3xl font-bold text-green-600 mt-2">{stats.completed}</div>
|
||||
</div>
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<div className="text-sm font-medium text-gray-500">Failed</div>
|
||||
<div className="text-3xl font-bold text-red-600 mt-2">{stats.failed}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Scans */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Recent Scans</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200">
|
||||
{scans.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center text-gray-500">
|
||||
No scans yet. Create your first scan to get started!
|
||||
</div>
|
||||
) : (
|
||||
scans.map((scan) => (
|
||||
{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="block px-6 py-4 hover:bg-gray-50 cursor-pointer"
|
||||
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-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="font-medium text-gray-900">
|
||||
<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>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded-full ${getStatusColor(
|
||||
scan.status
|
||||
)}`}
|
||||
>
|
||||
{scan.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
{scan.start_date} to {scan.end_date} • {scan.adults} adult(s) • {scan.seat_class}
|
||||
<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 && (
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
{scan.total_routes} routes • {scan.total_flights} flights found
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-on-surface-variant">
|
||||
{scan.total_routes} routes · {scan.total_flights} flights found
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{formatDate(scan.created_at)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user