phase 16: React dashboard with full platform control and analytics
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Shared reusable UI components: StatusBadge, ConfidenceBar, TrendArrow,
|
||||
* DateRangeSelector, TickerFilter, LoadingSpinner, ErrorBoundary.
|
||||
* Requirements: 13.1, 13.2
|
||||
*/
|
||||
import { Component, type ReactNode, type ChangeEvent } from 'react';
|
||||
import { TrendingUp, TrendingDown, Minus, Loader2 } from 'lucide-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StatusBadge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
completed: 'bg-green-900/40 text-green-400 border-green-700/50',
|
||||
success: 'bg-green-900/40 text-green-400 border-green-700/50',
|
||||
valid: 'bg-green-900/40 text-green-400 border-green-700/50',
|
||||
active: 'bg-green-900/40 text-green-400 border-green-700/50',
|
||||
approved: 'bg-green-900/40 text-green-400 border-green-700/50',
|
||||
filled: 'bg-green-900/40 text-green-400 border-green-700/50',
|
||||
running: 'bg-blue-900/40 text-blue-400 border-blue-700/50',
|
||||
pending: 'bg-yellow-900/40 text-yellow-400 border-yellow-700/50',
|
||||
failed: 'bg-red-900/40 text-red-400 border-red-700/50',
|
||||
rejected: 'bg-red-900/40 text-red-400 border-red-700/50',
|
||||
cancelled: 'bg-gray-800/40 text-gray-400 border-gray-700/50',
|
||||
disabled: 'bg-gray-800/40 text-gray-400 border-gray-700/50',
|
||||
paper: 'bg-purple-900/40 text-purple-400 border-purple-700/50',
|
||||
live: 'bg-orange-900/40 text-orange-400 border-orange-700/50',
|
||||
buy: 'bg-green-900/40 text-green-400 border-green-700/50',
|
||||
sell: 'bg-red-900/40 text-red-400 border-red-700/50',
|
||||
hold: 'bg-yellow-900/40 text-yellow-400 border-yellow-700/50',
|
||||
watch: 'bg-blue-900/40 text-blue-400 border-blue-700/50',
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: string | null | undefined }) {
|
||||
const s = (status ?? 'unknown').toLowerCase();
|
||||
const cls = statusColors[s] ?? 'bg-gray-800/40 text-gray-400 border-gray-700/50';
|
||||
return (
|
||||
<span className={`inline-block rounded-full border px-2 py-0.5 text-xs font-medium ${cls}`}>
|
||||
{s}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConfidenceBar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ConfidenceBar({ value, className = '' }: { value: number | null | undefined; className?: string }) {
|
||||
const pct = Math.round((value ?? 0) * 100);
|
||||
const color = pct >= 70 ? 'bg-green-500' : pct >= 40 ? 'bg-yellow-500' : 'bg-red-500';
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`} role="meter" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100} aria-label={`Confidence ${pct}%`}>
|
||||
<div className="h-1.5 w-20 rounded-full bg-surface-700">
|
||||
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{pct}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TrendArrow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TrendArrow({ direction }: { direction: string | null | undefined }) {
|
||||
const d = (direction ?? 'neutral').toLowerCase();
|
||||
if (d === 'bullish') return <TrendingUp size={16} className="text-green-400" aria-label="Bullish" />;
|
||||
if (d === 'bearish') return <TrendingDown size={16} className="text-red-400" aria-label="Bearish" />;
|
||||
return <Minus size={16} className="text-gray-500" aria-label={d === 'mixed' ? 'Mixed' : 'Neutral'} />;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DateRangeSelector
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function DateRangeSelector({ value, onChange }: { value: number; onChange: (hours: number) => void }) {
|
||||
const options = [
|
||||
{ label: '1h', hours: 1 },
|
||||
{ label: '6h', hours: 6 },
|
||||
{ label: '24h', hours: 24 },
|
||||
{ label: '7d', hours: 168 },
|
||||
];
|
||||
return (
|
||||
<div className="inline-flex rounded-md border border-surface-700" role="group" aria-label="Time range">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.hours}
|
||||
onClick={() => onChange(opt.hours)}
|
||||
className={`px-3 py-1 text-xs font-medium transition-colors first:rounded-l-md last:rounded-r-md ${
|
||||
value === opt.hours ? 'bg-brand-600 text-white' : 'bg-surface-900 text-gray-400 hover:bg-surface-800'
|
||||
}`}
|
||||
aria-pressed={value === opt.hours}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TickerFilter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TickerFilter({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ticker…"
|
||||
value={value}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.value.toUpperCase())}
|
||||
className="w-24 rounded-md border border-surface-700 bg-surface-900 px-2 py-1 text-xs text-gray-200 placeholder-gray-500 focus:border-brand-500 focus:outline-none"
|
||||
aria-label="Filter by ticker"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LoadingSpinner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function LoadingSpinner({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center py-12 ${className}`} role="status" aria-label="Loading">
|
||||
<Loader2 size={24} className="animate-spin text-brand-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ErrorBoundary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EBProps { children: ReactNode; fallback?: ReactNode }
|
||||
interface EBState { error: Error | null }
|
||||
|
||||
export class ErrorBoundary extends Component<EBProps, EBState> {
|
||||
state: EBState = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return this.props.fallback ?? (
|
||||
<div className="rounded-lg border border-red-700/50 bg-red-900/20 p-4 text-sm text-red-400" role="alert">
|
||||
Something went wrong: {this.state.error.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card — generic container
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function Card({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={`rounded-lg border border-surface-700 bg-surface-900 p-4 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user