phase 16: React dashboard with full platform control and analytics

This commit is contained in:
Celes Renata
2026-04-11 16:19:46 -07:00
parent 25e0e386b7
commit faccb0b8db
53 changed files with 7924 additions and 13 deletions
+105
View File
@@ -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>
);
}
+144
View File
@@ -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>
);
}
+166
View File
@@ -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>
);
}