Files
autofirmer-expanded/app/page.tsx
T
Brandon LiandClaude Opus 5 3ce14d4928 Sink firms with no login to the bottom, and name the account type plainly
A firm with no stored Tradovate login can do nothing until someone enters
one, so it no longer interleaves by name with the firms that are actually
running. Sorted at render rather than in /api/firms, which keeps serving the
catalog in the master's own order. Name order is preserved within each group.

The account-type picker showed "Auto — Flex 50k Funded", which put the
mechanism before the answer in the one place the answer is what you are
reading. It shows the type's name now, or Unassigned when nothing matched.
That first entry still clears a manual pin; the dot beside the select is what
says a pin is in effect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 23:04:48 -05:00

832 lines
40 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import type { FirmConfig, FirmState, AccountState } from '@/types';
import { stagePillClasses } from '@/lib/stage-colors';
type SortKey = 'name' | 'balance' | 'dayPnL' | 'daysTraded' | 'target' | 'status';
type SortDir = 'asc' | 'desc';
function statusRank(account: AccountState): number {
if (isAccountDead(account)) return 0;
if (!account.active) return 1;
if (!account.positionDirection) {
if (account.targetHit) return 4; // Target Hit — most accomplished
return 2; // Flat
}
return 3; // In Trade
}
function fmt(value: number) {
return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
/** Returns true when an account's balance has breached Tradovate's auto-liquidation floor. */
function isAccountDead(account: AccountState): boolean {
return account.autoLiqThreshold > 0 && account.amount <= account.autoLiqThreshold;
}
function SettingsIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
);
}
function KeyOffIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="7.5" cy="15.5" r="4.5" />
<path d="M10.7 12.3 21 2" />
<path d="m17 6 3 3" />
<line x1="2" y1="2" x2="22" y2="22" />
</svg>
);
}
function ChevronIcon({ open }: { open: boolean }) {
return (
<svg className={`chevron${open ? ' open' : ''}`} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
);
}
function DetailsIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
);
}
function EyeIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
}
function EyeOffIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
);
}
function maskName(name: string, privacy: boolean): string {
if (!privacy) return name;
const visible = name.slice(0, 5);
const hidden = name.slice(5);
return hidden.length > 0 ? visible + '•'.repeat(hidden.length) : visible;
}
function SortHeader({ label, col, sortKey, sortDir, onSort }: {
label: string;
col: SortKey;
sortKey: SortKey | null;
sortDir: SortDir;
onSort: (col: SortKey) => void;
}) {
const active = sortKey === col;
return (
<th
className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider cursor-pointer select-none transition-colors group"
onClick={() => onSort(col)}
>
<div className="flex items-center gap-1">
<span className={active ? 'text-slate-600' : 'text-slate-400 group-hover:text-slate-500'}>
{label}
</span>
<span className={`text-[10px] ${active ? 'text-slate-500' : 'text-slate-300 group-hover:text-slate-400'}`}>
{active ? (sortDir === 'asc' ? '↑' : '↓') : '↕'}
</span>
</div>
</th>
);
}
/** The account-type override. "Auto" hands the account back to the resolver;
* anything else pins it, and a pin survives every later sync. */
function AccountTypeCell({ account, firm, onAssigned }: {
account: AccountState;
firm: FirmConfig;
onAssigned: () => void;
}) {
const [saving, setSaving] = useState(false);
async function assign(accountTypeId: string) {
setSaving(true);
try {
if (accountTypeId === '') {
await fetch(`/api/accounts/${account.id}/account-type`, { method: 'DELETE' });
} else {
await fetch(`/api/accounts/${account.id}/account-type`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accountTypeId }),
});
}
onAssigned();
} finally {
setSaving(false);
}
}
// A pinned type that no longer exists on the master: keep the pin, but say
// so, because the account has silently stopped trading.
const danglingPin = account.assignmentSource === 'unassigned' && account.accountTypeId !== null;
return (
<div className="flex items-center gap-1.5">
<select
value={account.assignmentSource === 'manual' ? (account.accountTypeId ?? '') : ''}
disabled={saving}
onClick={(e) => e.stopPropagation()}
onChange={(e) => assign(e.target.value)}
className={`border border-slate-200 rounded-md px-1.5 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 max-w-[10rem] ${
account.accountTypeLabel ? 'text-slate-700' : 'text-slate-400'
}`}
>
<option value="">
{account.accountTypeLabel ?? 'Unassigned'}
</option>
{firm.accounts.map((type) => (
<option key={type.id} value={type.id}>
{type.label} · {type.prefix} · {type.accountSize >= 1000 ? `${type.accountSize / 1000}k` : type.accountSize}
</option>
))}
</select>
{account.assignmentSource === 'manual' && (
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 shrink-0" title="Pinned by hand" />
)}
{account.accountTypeAmbiguous && (
<span className="text-amber-500 text-xs font-bold shrink-0" title="Several account types matched — using the most recently updated one">*</span>
)}
{danglingPin && (
<span className="text-amber-600 text-xs shrink-0" title="The pinned account type no longer exists on the master — this account will not trade">!</span>
)}
{!account.accountTypeLabel && !danglingPin && (
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 text-amber-700 shrink-0">
Unassigned
</span>
)}
</div>
);
}
/** Inline credential entry, shown to the right of a firm that has none. */
function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
async function save() {
if (!username.trim() || !password.trim()) return;
setSaving(true);
setError('');
try {
const res = await fetch(`/api/firms/${firm.id}/credentials`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'Failed');
setUsername('');
setPassword('');
onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed');
} finally {
setSaving(false);
}
}
const cls = 'border border-slate-200 rounded-md px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 w-32';
return (
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
<input
className={cls}
placeholder="Tradovate username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
className={cls}
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void save(); }}
/>
<button
onClick={save}
disabled={saving || !username.trim() || !password.trim()}
className="px-2.5 py-1 bg-blue-100 hover:bg-blue-200 disabled:opacity-50 text-blue-700 text-xs font-semibold rounded-md transition-colors"
>
{saving ? '…' : 'Save'}
</button>
{error && <span className="text-xs text-red-500">{error}</span>}
</div>
);
}
function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
account: AccountState;
firm: FirmConfig;
hideDead: boolean;
privacy: boolean;
onAssigned: () => void;
}) {
const cfg = account.accountType;
const dead = isAccountDead(account);
if (hideDead && dead) return null;
const pnlColor = account.realizedPnL > 0 ? 'text-green-600 font-medium' : account.realizedPnL < 0 ? 'text-red-600 font-medium' : 'text-slate-400';
return (
<tr className={`border-b border-slate-100 hover:bg-slate-50 transition-colors${!account.active || dead ? ' opacity-50' : ''}`}>
<td className="px-4 py-3">
<div className="flex items-center gap-2 font-medium text-slate-800 pl-4 font-mono">
{maskName(account.name, privacy)}
</div>
</td>
<td className="px-4 py-3 text-slate-600 tabular-nums">${fmt(account.amount)}</td>
<td className={`px-4 py-3 tabular-nums ${pnlColor}`}>
{account.realizedPnL >= 0 ? '+' : ''}${fmt(account.realizedPnL)}
</td>
<td className="px-4 py-3">
<AccountTypeCell account={account} firm={firm} onAssigned={onAssigned} />
</td>
<td className="px-4 py-3 text-slate-600 tabular-nums">
{account.daysTraded} / {cfg?.minTradingDays ?? '—'}
</td>
<td className="px-4 py-3 text-slate-600 tabular-nums">
<div className="flex flex-col">
<div className="flex items-center gap-1.5">
<span>${(account.effectiveProfitTarget ?? cfg?.profitTarget ?? 0).toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold ${stagePillClasses(account.stage)}`}>
Stage {account.stage}
</span>
</div>
{account.dailyTarget && (
<span className="text-xs text-slate-400">${fmt(account.dailyTarget.amount)} today</span>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-between">
{dead
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-800 text-white">Dead</span>
: !account.active
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-700">Hit DLL</span>
: account.positionDirection
? <span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold ${account.positionDirection === 'long' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>{account.positionDirection === 'long' ? 'Long' : 'Short'}</span>
: account.targetHit
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-700">Target Hit</span>
: <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-500">Flat</span>}
<Link
href={`/accounts/${account.id}`}
className="text-slate-400 hover:text-slate-600 hover:bg-slate-200 p-1 rounded-md transition-colors inline-flex"
title="View details"
onClick={(e) => e.stopPropagation()}
>
<DetailsIcon />
</Link>
</div>
</td>
</tr>
);
}
function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbol, onChanged }: {
state: FirmState;
firm: FirmConfig;
hideDead: boolean;
privacy: boolean;
sortKey: SortKey | null;
sortDir: SortDir;
tradeSymbol: string;
onChanged: () => void;
}) {
const [open, setOpen] = useState(true);
const [confirmCreds, setConfirmCreds] = useState(false);
const [removingCreds, setRemovingCreds] = useState(false);
const hasCredentials = firm.hasCredentials;
// Clearing the login drops the Tradovate client, so the firm's accounts go
// with it until someone enters one again — worth a confirm step.
async function removeCredentials() {
setRemovingCreds(true);
try {
await fetch(`/api/firms/${firm.id}/credentials`, { method: 'DELETE' });
onChanged();
} catch {
/* the row simply stays as it was */
} finally {
setRemovingCreds(false);
setConfirmCreds(false);
}
}
const sortedAccounts = sortKey === null ? state.accounts : [...state.accounts].sort((a, b) => {
const dir = sortDir === 'asc' ? 1 : -1;
switch (sortKey) {
case 'name': return dir * a.name.localeCompare(b.name);
case 'balance': return dir * (a.amount - b.amount);
case 'dayPnL': return dir * (a.realizedPnL - b.realizedPnL);
case 'daysTraded': return dir * (a.daysTraded - b.daysTraded);
case 'target': {
const at = a.effectiveProfitTarget ?? a.accountType?.profitTarget ?? 0;
const bt = b.effectiveProfitTarget ?? b.accountType?.profitTarget ?? 0;
return dir * (at - bt);
}
case 'status': return dir * (statusRank(a) - statusRank(b));
default: return 0;
}
});
return (
<>
<tr
className={`border-t border-slate-200 bg-slate-50 transition-colors ${
hasCredentials ? 'hover:bg-slate-100 cursor-pointer select-none' : 'opacity-50'
}`}
onClick={() => { if (hasCredentials) setOpen((o) => !o); }}
>
<td className="px-4 py-3.5">
<div className="flex items-center gap-2.5">
<span className="font-bold text-sm text-slate-800">{firm.firm}</span>
{firm.bannedSymbols?.includes(tradeSymbol) && (
<span className="text-xs bg-red-100 text-red-700 px-1.5 py-0.5 rounded font-semibold">
{tradeSymbol} BANNED
</span>
)}
{hasCredentials && !state.connected && (
<span className="text-xs bg-slate-200 text-slate-600 px-1.5 py-0.5 rounded font-semibold">
Connecting…
</span>
)}
{hasCredentials && state.connected && state.accounts.length === 0 && (
<span className="text-xs bg-slate-100 text-slate-500 px-1.5 py-0.5 rounded font-semibold">
No accounts
</span>
)}
</div>
</td>
{!hasCredentials ? (
<td colSpan={5} className="px-4 py-2.5">
<CredentialForm firm={firm} onSaved={onChanged} />
</td>
) : confirmCreds ? (
<td colSpan={5} className="px-4 py-2.5">
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<span className="text-xs text-slate-500">
Remove the saved Tradovate login for {firm.firm}? Its accounts disappear until you enter one again.
</span>
<button
onClick={removeCredentials}
disabled={removingCreds}
className="px-2.5 py-1 bg-red-100 hover:bg-red-200 disabled:opacity-50 text-red-700 text-xs font-semibold rounded-md transition-colors"
>
{removingCreds ? '…' : 'Remove'}
</button>
<button
onClick={() => setConfirmCreds(false)}
className="px-2.5 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 text-xs font-semibold rounded-md transition-colors"
>
Cancel
</button>
</div>
</td>
) : (
<><td /><td /><td /><td /><td /></>
)}
<td className="px-4 py-3.5">
<div className="flex items-center justify-end gap-1">
{hasCredentials && !confirmCreds && (
<button
onClick={(e) => { e.stopPropagation(); setConfirmCreds(true); }}
className="text-slate-400 hover:text-red-500 hover:bg-red-50 p-1 rounded-md transition-colors inline-flex"
title="Remove saved Tradovate login"
>
<KeyOffIcon />
</button>
)}
<Link
href={`/firms/${firm.id}/settings`}
className="text-slate-400 hover:text-slate-600 hover:bg-slate-200 p-1 rounded-md transition-colors inline-flex"
onClick={(e) => e.stopPropagation()}
title="Account types"
>
<SettingsIcon />
</Link>
{hasCredentials && <ChevronIcon open={open} />}
</div>
</td>
</tr>
{open && hasCredentials && (
state.accounts.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-5 pl-12 text-sm text-slate-400 italic">
{state.connected ? 'No accounts at this firm' : 'Connecting to Tradovate…'}
</td>
</tr>
) : (
sortedAccounts.map((acc) => (
<AccountRow
key={acc.id}
account={acc}
firm={firm}
hideDead={hideDead}
privacy={privacy}
onAssigned={onChanged}
/>
))
)
)}
</>
);
}
interface SchedulerStatus {
running: boolean;
action: 'Buy' | 'Sell' | 'Auto';
symbol: string;
lastRun: string | null;
intervalSeconds: number;
stopAfterAll: boolean;
}
export default function Home() {
const [config, setConfig] = useState<FirmConfig[]>([]);
const [firms, setFirms] = useState<FirmState[]>([]);
const [catalog, setCatalog] = useState<{ lastSyncAt: string | null; lastError: string | null; masterUrl: string | null } | null>(null);
const [hideDead, setHideDead] = useState(false);
const [privacy, setPrivacy] = useState(false);
const [sortKey, setSortKey] = useState<SortKey | null>(null);
const [sortDir, setSortDir] = useState<SortDir>('asc');
// Trade controls
const [scheduler, setScheduler] = useState<SchedulerStatus>({ running: false, action: 'Buy', symbol: 'NQ', lastRun: null, intervalSeconds: 60, stopAfterAll: false });
const [enabledSymbols, setEnabledSymbols] = useState<string[]>([]);
const [tradeSymbol, setTradeSymbol] = useState('');
const [tradeAction, setTradeAction] = useState<'Buy' | 'Sell' | 'Auto'>('Buy');
const [tickInterval, setTickInterval] = useState('60');
const [tradeLoading, setTradeLoading] = useState(false);
const [stopAfterAll, setStopAfterAll] = useState(false);
const handleSort = (col: SortKey) => {
if (sortKey === col) {
setSortDir((d) => d === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(col);
setSortDir('asc');
}
};
const fetchConfig = async () => {
try {
const res = await fetch('/api/firms');
if (res.ok) {
const data: FirmConfig[] = await res.json();
setConfig(data);
}
} catch {
// server not running yet
}
};
const fetchCatalog = async () => {
try {
const res = await fetch('/api/firm-catalog');
if (res.ok) setCatalog(await res.json());
} catch { /* server not running yet */ }
};
const syncCatalogNow = async () => {
try {
await fetch('/api/firm-catalog', { method: 'POST' });
} catch { /* ignore */ }
fetchCatalog();
fetchConfig();
};
const fetchScheduler = async () => {
try {
const res = await fetch('/api/auto-trade');
if (res.ok) setScheduler(await res.json());
} catch { /* ignore */ }
};
useEffect(() => {
fetch('/api/instruments')
.then((r) => r.json() as Promise<{ symbol: string; enabled: boolean }[]>)
.then((instruments) => {
const syms = instruments.filter((i) => i.enabled).map((i) => i.symbol);
setEnabledSymbols(syms);
setTradeSymbol((prev) => prev || syms[0] || 'NQ');
})
.catch(() => {});
fetch('/api/settings')
.then((r) => r.json())
.then((s: { tick_interval_seconds?: string | null }) => {
if (s.tick_interval_seconds != null) setTickInterval(s.tick_interval_seconds);
})
.catch(() => {});
fetchConfig();
fetchCatalog();
fetchScheduler();
const fetchState = async () => {
try {
const res = await fetch('/api/state');
if (res.ok) {
const data: FirmState[] = await res.json();
setFirms(data);
}
} catch {
// server not running yet
}
};
fetchState();
const stateInterval = setInterval(fetchState, 5000);
const schedulerInterval = setInterval(fetchScheduler, 5000);
return () => { clearInterval(stateInterval); clearInterval(schedulerInterval); };
}, []);
const handleStart = async () => {
setTradeLoading(true);
try {
await fetch('/api/trade', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: tradeAction, symbol: tradeSymbol, stopAfterAll }),
});
await fetchScheduler();
} finally {
setTradeLoading(false);
}
};
const handleStop = async () => {
await fetch('/api/auto-trade', { method: 'DELETE' });
setScheduler((s) => ({ ...s, running: false, lastRun: null }));
};
const hasAnyPosition = firms.some(f => f.accounts.some(a => a.positionDirection !== null));
const [copyLoading, setCopyLoading] = useState(false);
const handleCopy = async () => {
setCopyLoading(true);
try {
await fetch('/api/copy-trade', { method: 'POST' });
} finally {
setCopyLoading(false);
}
};
// A firm with no stored login can do nothing until someone enters one, so it
// sits below the ones that are actually running rather than interleaving by
// name. Within each group, catalog order (by name) is preserved.
const orderedConfig = [...config].sort((a, b) =>
Number(b.hasCredentials) - Number(a.hasCredentials) || a.firm.localeCompare(b.firm));
// Count dead accounts across all firms for the toggle button label
const deadCount = firms.reduce((total, firmState) => {
const firmCfg = config.find((c) => c.id === firmState.firmId);
if (!firmCfg) return total;
return total + firmState.accounts.filter((acc) => isAccountDead(acc)).length;
}, 0);
return (
<div className="min-h-screen bg-slate-50 p-8">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-slate-900">AutoTrader</h1>
<div className="flex items-center gap-2">
{deadCount > 0 && (
<button
onClick={() => setHideDead((h) => !h)}
className={`px-4 py-2 text-sm font-semibold rounded-lg transition-colors border ${
hideDead
? 'bg-slate-800 text-white border-slate-800 hover:bg-slate-700'
: 'bg-white text-slate-600 border-slate-200 hover:bg-slate-100'
}`}
>
{hideDead ? `Show Dead (${deadCount})` : `Hide Dead (${deadCount})`}
</button>
)}
<button
onClick={() => setPrivacy((p) => !p)}
className={`px-3 py-2 rounded-lg transition-colors inline-flex items-center ${privacy ? 'text-blue-600 bg-blue-50 hover:bg-blue-100' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'}`}
title={privacy ? 'Show account names' : 'Hide account names'}
>
{privacy ? <EyeOffIcon /> : <EyeIcon />}
</button>
<Link
href="/settings"
className="px-3 py-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors inline-flex items-center"
title="Global Settings"
>
<SettingsIcon />
</Link>
</div>
</div>
{/* ── Trade Controls ── */}
<div className="bg-white border border-slate-200 rounded-xl px-4 py-3 mb-4 shadow-sm flex items-center gap-3">
{scheduler.running ? (
<>
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse flex-shrink-0" />
<span className="text-sm font-semibold text-green-700">
{scheduler.symbol} · {scheduler.action}
</span>
<span className="text-xs text-slate-400">every {scheduler.intervalSeconds}s</span>
{scheduler.lastRun && (
<span className="text-xs text-slate-400">
· last run {new Date(scheduler.lastRun).toLocaleTimeString()}
</span>
)}
<div className="ml-auto flex items-center gap-2">
{hasAnyPosition && (
<button
onClick={handleCopy}
disabled={copyLoading}
className="px-4 py-1.5 bg-amber-100 hover:bg-amber-200 disabled:opacity-50 text-amber-700 text-sm font-semibold rounded-lg transition-colors"
>
{copyLoading ? 'Copying…' : 'Copy to Max'}
</button>
)}
<button
onClick={handleStop}
className="px-4 py-1.5 bg-red-100 hover:bg-red-200 text-red-700 text-sm font-semibold rounded-lg transition-colors"
>
■ Stop
</button>
</div>
</>
) : (
<>
<span className="w-2 h-2 rounded-full bg-slate-300 flex-shrink-0" />
<span className="text-sm text-slate-400 font-medium">Idle</span>
<div className="flex items-center gap-2 ml-3">
<select
value={tradeSymbol}
onChange={(e) => setTradeSymbol(e.target.value)}
className="rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{enabledSymbols.map((s) => <option key={s} value={s}>{s}</option>)}
<option value="Auto">Auto</option>
</select>
<div className="flex rounded-lg border border-slate-200 overflow-hidden text-sm font-semibold">
<button
onClick={() => setTradeAction('Buy')}
className={`px-3 py-1.5 transition-colors ${tradeAction === 'Buy' ? 'bg-green-500 text-white' : 'text-slate-500 hover:bg-slate-50'}`}
>
Buy
</button>
<button
onClick={() => setTradeAction('Sell')}
className={`px-3 py-1.5 transition-colors border-l border-slate-200 ${tradeAction === 'Sell' ? 'bg-red-500 text-white' : 'text-slate-500 hover:bg-slate-50'}`}
>
Sell
</button>
<button
onClick={() => setTradeAction('Auto')}
className={`px-3 py-1.5 transition-colors border-l border-slate-200 ${tradeAction === 'Auto' ? 'bg-purple-500 text-white' : 'text-slate-500 hover:bg-slate-50'}`}
>
Auto
</button>
</div>
<div className="flex items-center gap-1.5 ml-1">
<label className="text-xs text-slate-400 whitespace-nowrap">Every</label>
<input
type="number"
min={5}
max={3600}
value={tickInterval}
onChange={(e) => setTickInterval(e.target.value)}
onBlur={() => {
const secs = Math.max(5, parseInt(tickInterval, 10) || 60);
setTickInterval(String(secs));
fetch('/api/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tick_interval_seconds: secs }),
}).catch(() => {});
}}
className="w-16 rounded-lg border border-slate-200 bg-slate-50 px-2 py-1.5 text-sm text-right font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<span className="text-xs text-slate-400">s</span>
</div>
</div>
<label className="flex items-center gap-1.5 ml-2 cursor-pointer select-none">
<input
type="checkbox"
checked={stopAfterAll}
onChange={(e) => setStopAfterAll(e.target.checked)}
className="rounded border-slate-300 text-blue-500 focus:ring-blue-500"
/>
<span className="text-xs text-slate-400 whitespace-nowrap">Stop after all eligible</span>
</label>
<div className="ml-auto flex items-center gap-2">
{hasAnyPosition && (
<button
onClick={handleCopy}
disabled={copyLoading}
className="px-4 py-1.5 bg-amber-100 hover:bg-amber-200 disabled:opacity-50 text-amber-700 text-sm font-semibold rounded-lg transition-colors"
>
{copyLoading ? 'Copying…' : 'Copy to Max'}
</button>
)}
<button
onClick={handleStart}
disabled={tradeLoading}
className="px-4 py-1.5 bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white text-sm font-semibold rounded-lg transition-colors"
>
{tradeLoading ? 'Starting…' : '▶ Start'}
</button>
</div>
</>
)}
</div>
<div className="w-full bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<SortHeader label="Account" col="name" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<SortHeader label="Balance" col="balance" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<SortHeader label="Day P&L" col="dayPnL" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Account Type</th>
<SortHeader label="Days Traded" col="daysTraded" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<SortHeader label="Target" col="target" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
<SortHeader label="Status" col="status" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} />
</tr>
</thead>
<tbody>
{config.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-sm text-slate-400 italic">
{catalog && catalog.lastSyncAt === null ? (
<>
Catalog never synced — cannot reach {catalog.masterUrl ?? 'the master dashboard'}
{catalog.lastError && <><br />{catalog.lastError}</>}
<br />
<button
onClick={syncCatalogNow}
className="mt-2 px-3 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 text-xs font-semibold rounded-md not-italic transition-colors"
>
Retry
</button>
</>
) : (
<>No firms yet — add them on the master dashboard</>
)}
</td>
</tr>
) : (
orderedConfig.map((cfg) => {
const state = firms.find((f) => f.firmId === cfg.id)
?? { firmId: cfg.id, firm: cfg.firm, connected: false, accounts: [] };
return (
<FirmRows
key={cfg.id}
state={state}
firm={cfg}
hideDead={hideDead}
privacy={privacy}
sortKey={sortKey}
sortDir={sortDir}
tradeSymbol={tradeSymbol}
onChanged={fetchConfig}
/>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
);
}