phase 16: React dashboard with full platform control and analytics
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link, useRouterState } from '@tanstack/react-router';
|
||||
import {
|
||||
Home,
|
||||
Building2,
|
||||
FileText,
|
||||
TrendingUp,
|
||||
Lightbulb,
|
||||
ShoppingCart,
|
||||
Wallet,
|
||||
ShieldCheck,
|
||||
Activity,
|
||||
Download,
|
||||
Cpu,
|
||||
Radar,
|
||||
Terminal,
|
||||
LayoutDashboard,
|
||||
List,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ to: '/', label: 'Home', icon: <Home size={18} /> },
|
||||
{ to: '/companies', label: 'Companies', icon: <Building2 size={18} />, group: 'Data' },
|
||||
{ to: '/watchlists', label: 'Watchlists', icon: <List size={18} />, group: 'Data' },
|
||||
{ to: '/documents', label: 'Documents', icon: <FileText size={18} />, group: 'Data' },
|
||||
{ to: '/trends', label: 'Trends', icon: <TrendingUp size={18} />, group: 'Intelligence' },
|
||||
{ to: '/recommendations', label: 'Recommendations', icon: <Lightbulb size={18} />, group: 'Intelligence' },
|
||||
{ to: '/orders', label: 'Orders', icon: <ShoppingCart size={18} />, group: 'Trading' },
|
||||
{ to: '/positions', label: 'Positions', icon: <Wallet size={18} />, group: 'Trading' },
|
||||
{ to: '/trading', label: 'Trading Controls', icon: <ShieldCheck size={18} />, group: 'Trading' },
|
||||
{ to: '/ops/pipeline', label: 'Pipeline', icon: <Activity size={18} />, group: 'Ops' },
|
||||
{ to: '/ops/ingestion', label: 'Ingestion', icon: <Download size={18} />, group: 'Ops' },
|
||||
{ to: '/ops/model', label: 'Model Perf', icon: <Cpu size={18} />, group: 'Ops' },
|
||||
{ to: '/ops/coverage', label: 'Coverage', icon: <Radar size={18} />, group: 'Ops' },
|
||||
{ to: '/analytics/query', label: 'SQL Explorer', icon: <Terminal size={18} />, group: 'Analytics' },
|
||||
{ to: '/analytics/dashboards', label: 'Dashboards', icon: <LayoutDashboard size={18} />, group: 'Analytics' },
|
||||
];
|
||||
|
||||
export function AppLayout({ children }: { children: ReactNode }) {
|
||||
const routerState = useRouterState();
|
||||
const currentPath = routerState.location.pathname;
|
||||
|
||||
let lastGroup: string | undefined;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<nav
|
||||
className="w-56 shrink-0 bg-surface-900 border-r border-surface-700 flex flex-col"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<div className="px-4 py-4 border-b border-surface-700">
|
||||
<span className="text-lg font-bold tracking-tight text-brand-400">
|
||||
Stonks Oracle
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-2 px-2 space-y-0.5">
|
||||
{navItems.map((item) => {
|
||||
const showGroup = item.group && item.group !== lastGroup;
|
||||
lastGroup = item.group;
|
||||
const active =
|
||||
item.to === '/'
|
||||
? currentPath === '/'
|
||||
: currentPath.startsWith(item.to);
|
||||
|
||||
return (
|
||||
<div key={item.to}>
|
||||
{showGroup && (
|
||||
<div className="px-2 pt-4 pb-1 text-[11px] font-semibold uppercase tracking-wider text-gray-500">
|
||||
{item.group}
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
to={item.to}
|
||||
className={`flex items-center gap-2.5 px-2.5 py-1.5 rounded-md text-sm transition-colors ${
|
||||
active
|
||||
? 'bg-brand-600/20 text-brand-300'
|
||||
: 'text-gray-400 hover:bg-surface-800 hover:text-gray-200'
|
||||
}`}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-y-auto bg-surface-950 p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useState, useMemo, type ReactNode } from 'react';
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
header: string;
|
||||
render?: (row: T) => ReactNode;
|
||||
sortable?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface Props<T> {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
keyField: keyof T & string;
|
||||
pageSize?: number;
|
||||
onRowClick?: (row: T) => void;
|
||||
filterFn?: (row: T, query: string) => boolean;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
export function DataTable<T extends object>({
|
||||
data,
|
||||
columns,
|
||||
keyField,
|
||||
pageSize = 25,
|
||||
onRowClick,
|
||||
filterFn,
|
||||
emptyMessage = 'No data',
|
||||
}: Props<T>) {
|
||||
const [sortKey, setSortKey] = useState<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||
const [page, setPage] = useState(0);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!filter || !filterFn) return data;
|
||||
return data.filter((row) => filterFn(row, filter));
|
||||
}, [data, filter, filterFn]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!sortKey) return filtered;
|
||||
return [...filtered].sort((a, b) => {
|
||||
const av = (a as Record<string, unknown>)[sortKey];
|
||||
const bv = (b as Record<string, unknown>)[sortKey];
|
||||
if (av == null && bv == null) return 0;
|
||||
if (av == null) return 1;
|
||||
if (bv == null) return -1;
|
||||
const cmp = av < bv ? -1 : av > bv ? 1 : 0;
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}, [filtered, sortKey, sortDir]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
|
||||
const paged = sorted.slice(page * pageSize, (page + 1) * pageSize);
|
||||
|
||||
function toggleSort(key: string) {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('asc');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{filterFn && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter…"
|
||||
value={filter}
|
||||
onChange={(e) => { setFilter(e.target.value); setPage(0); }}
|
||||
className="mb-3 w-full max-w-xs rounded-md border border-surface-700 bg-surface-900 px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:border-brand-500 focus:outline-none"
|
||||
aria-label="Filter table"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-surface-700">
|
||||
<table className="w-full text-sm" role="grid">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-700 bg-surface-900">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-3 py-2 text-left font-medium text-gray-400 ${col.className ?? ''} ${col.sortable !== false ? 'cursor-pointer select-none hover:text-gray-200' : ''}`}
|
||||
onClick={() => col.sortable !== false && toggleSort(col.key)}
|
||||
aria-sort={sortKey === col.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{col.header}
|
||||
{col.sortable !== false && (
|
||||
sortKey === col.key
|
||||
? (sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />)
|
||||
: <ChevronsUpDown size={14} className="opacity-30" />
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-3 py-8 text-center text-gray-500">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paged.map((row) => (
|
||||
<tr
|
||||
key={String((row as Record<string, unknown>)[keyField])}
|
||||
className={`border-b border-surface-700/50 ${onRowClick ? 'cursor-pointer hover:bg-surface-800/50' : ''}`}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className={`px-3 py-2 text-gray-300 ${col.className ?? ''}`}>
|
||||
{col.render ? col.render(row) : String((row as Record<string, unknown>)[col.key] ?? '—')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>{sorted.length} rows</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0} className="rounded px-2 py-1 hover:bg-surface-800 disabled:opacity-30" aria-label="Previous page">
|
||||
Prev
|
||||
</button>
|
||||
<span className="py-1">{page + 1} / {totalPages}</span>
|
||||
<button onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1} className="rounded px-2 py-1 hover:bg-surface-800 disabled:opacity-30" aria-label="Next page">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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