- Add withdrawal stage system: each stage defines profit target, consistency, and min trading days for post-withdrawal challenge cycles - Target Same Equity mode accounts for withdrawn amounts when computing effective profit target (profitTarget - remainingProfit) - Store fund transaction timestamps for time-aware cycle filtering (withdrawals before 9 AM CT include that day in new cycle) - Expose full P&L history (fullDailyPnL) for calendar/equity curve display across all cycles, with DB fallback for pre-restart data - Show stage number (#1, #2, etc.) on calendar cells - Hide consistency reference line when consistency is 0% or 100% - Settings UI: "After First W/D" column with same-equity checkbox, expandable stage sub-rows with profit/consistency/days inputs - Default target_same_equity to 1 for new and existing account configs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
649 lines
36 KiB
TypeScript
649 lines
36 KiB
TypeScript
'use client';
|
||
|
||
import React, { useEffect, useState } from 'react';
|
||
import { useRouter, useParams } from 'next/navigation';
|
||
|
||
const SIZE_PRESETS = [5_000, 10_000, 25_000, 50_000, 75_000, 100_000, 150_000];
|
||
|
||
interface AccountConfig {
|
||
id: number;
|
||
prefix: string;
|
||
profitTarget: number;
|
||
consistency: number;
|
||
minDayPnL: number;
|
||
minTradingDays: number;
|
||
accountSize: number;
|
||
maxPositionSize: number;
|
||
targetSameEquity: boolean;
|
||
withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[];
|
||
}
|
||
|
||
interface FirmConfig {
|
||
id: number;
|
||
firm: string;
|
||
accounts: AccountConfig[];
|
||
}
|
||
|
||
interface RowState extends AccountConfig {
|
||
dirty: boolean;
|
||
saving: boolean;
|
||
error: string;
|
||
useCustomSize: boolean;
|
||
confirmDelete: boolean;
|
||
}
|
||
|
||
function initRow(acc: AccountConfig): RowState {
|
||
return {
|
||
...acc,
|
||
dirty: false,
|
||
saving: false,
|
||
error: '',
|
||
useCustomSize: !SIZE_PRESETS.includes(acc.accountSize),
|
||
confirmDelete: false,
|
||
};
|
||
}
|
||
|
||
function newRow(): RowState {
|
||
return {
|
||
id: -(Date.now()),
|
||
prefix: '',
|
||
profitTarget: 3000,
|
||
consistency: 0.5,
|
||
minDayPnL: -999,
|
||
minTradingDays: 5,
|
||
accountSize: 50_000,
|
||
maxPositionSize: 0,
|
||
targetSameEquity: true,
|
||
withdrawalStages: [],
|
||
dirty: true,
|
||
saving: false,
|
||
error: '',
|
||
useCustomSize: false,
|
||
confirmDelete: false,
|
||
};
|
||
}
|
||
|
||
function fmtSize(n: number) {
|
||
return n >= 1000 ? `${n / 1000}k` : String(n);
|
||
}
|
||
|
||
function TrashIcon() {
|
||
return (
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="3 6 5 6 21 6" />
|
||
<path d="M19 6l-1 14H6L5 6" />
|
||
<path d="M10 11v6M14 11v6" />
|
||
<path d="M9 6V4h6v2" />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
const inputCls =
|
||
'border border-slate-200 rounded-md px-2 py-1.5 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white w-full';
|
||
|
||
export default function FirmSettingsPage() {
|
||
const router = useRouter();
|
||
const params = useParams();
|
||
const id = params.id as string;
|
||
|
||
const [firmName, setFirmName] = useState('');
|
||
const [rows, setRows] = useState<RowState[]>([]);
|
||
const [loadError, setLoadError] = useState('');
|
||
|
||
// Instruments (globally enabled only) and per-firm bans
|
||
const [instruments, setInstruments] = useState<string[]>([]);
|
||
const [bannedSymbols, setBannedSymbols] = useState<Set<string>>(new Set());
|
||
|
||
useEffect(() => {
|
||
fetch(`/api/firms/${id}`)
|
||
.then((r) => {
|
||
if (!r.ok) throw new Error('Not found');
|
||
return r.json() as Promise<FirmConfig>;
|
||
})
|
||
.then((firm) => {
|
||
setFirmName(firm.firm);
|
||
setRows(firm.accounts.map(initRow));
|
||
})
|
||
.catch(() => setLoadError('Failed to load firm'));
|
||
|
||
// Load globally-enabled instruments
|
||
fetch('/api/instruments')
|
||
.then((r) => r.json())
|
||
.then((data: { symbol: string; enabled: boolean }[]) =>
|
||
setInstruments(data.filter((i) => i.enabled).map((i) => i.symbol))
|
||
);
|
||
|
||
// Load this firm's banned symbols
|
||
fetch(`/api/firms/${id}/banned-symbols`)
|
||
.then((r) => r.json())
|
||
.then((syms: string[]) => setBannedSymbols(new Set(syms)));
|
||
}, [id]);
|
||
|
||
async function toggleBan(symbol: string) {
|
||
const nowBanned = !bannedSymbols.has(symbol);
|
||
setBannedSymbols((prev) => {
|
||
const next = new Set(prev);
|
||
if (nowBanned) next.add(symbol); else next.delete(symbol);
|
||
return next;
|
||
});
|
||
await fetch(`/api/firms/${id}/banned-symbols`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ symbol, banned: nowBanned }),
|
||
});
|
||
}
|
||
|
||
const updateRow = (rowId: number, patch: Partial<RowState>) => {
|
||
setRows((prev) =>
|
||
prev.map((r) => (r.id === rowId ? { ...r, ...patch, dirty: true } : r))
|
||
);
|
||
};
|
||
|
||
const saveRow = async (row: RowState) => {
|
||
setRows((prev) =>
|
||
prev.map((r) => (r.id === row.id ? { ...r, saving: true, error: '' } : r))
|
||
);
|
||
try {
|
||
const body = {
|
||
prefix: row.prefix,
|
||
profitTarget: row.profitTarget,
|
||
consistency: row.consistency,
|
||
minDayPnL: row.minDayPnL,
|
||
minTradingDays: row.minTradingDays,
|
||
accountSize: row.accountSize,
|
||
maxPositionSize: row.maxPositionSize,
|
||
targetSameEquity: row.targetSameEquity,
|
||
withdrawalStages: row.withdrawalStages,
|
||
};
|
||
|
||
if (row.id < 0) {
|
||
const res = await fetch(`/api/firms/${id}/accounts`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!res.ok) throw new Error('Failed');
|
||
const created = await res.json() as AccountConfig;
|
||
setRows((prev) =>
|
||
prev.map((r) =>
|
||
r.id === row.id ? { ...initRow(created), dirty: false } : r
|
||
)
|
||
);
|
||
} else {
|
||
const res = await fetch(`/api/account-configs/${row.id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!res.ok) throw new Error('Failed');
|
||
setRows((prev) =>
|
||
prev.map((r) =>
|
||
r.id === row.id ? { ...r, saving: false, dirty: false, error: '' } : r
|
||
)
|
||
);
|
||
}
|
||
} catch {
|
||
setRows((prev) =>
|
||
prev.map((r) =>
|
||
r.id === row.id ? { ...r, saving: false, error: 'Save failed' } : r
|
||
)
|
||
);
|
||
}
|
||
};
|
||
|
||
const confirmDeleteRow = (rowId: number) => {
|
||
setRows((prev) =>
|
||
prev.map((r) => (r.id === rowId ? { ...r, confirmDelete: true } : r))
|
||
);
|
||
};
|
||
|
||
const cancelDeleteRow = (rowId: number) => {
|
||
setRows((prev) =>
|
||
prev.map((r) => (r.id === rowId ? { ...r, confirmDelete: false } : r))
|
||
);
|
||
};
|
||
|
||
const deleteRow = async (row: RowState) => {
|
||
if (row.id < 0) {
|
||
setRows((prev) => prev.filter((r) => r.id !== row.id));
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch(`/api/account-configs/${row.id}`, { method: 'DELETE' });
|
||
if (!res.ok) throw new Error('Failed');
|
||
setRows((prev) => prev.filter((r) => r.id !== row.id));
|
||
} catch {
|
||
setRows((prev) =>
|
||
prev.map((r) =>
|
||
r.id === row.id ? { ...r, confirmDelete: false, error: 'Delete failed' } : r
|
||
)
|
||
);
|
||
}
|
||
};
|
||
|
||
if (loadError) {
|
||
return (
|
||
<div className="min-h-screen bg-slate-50 p-8">
|
||
<div className="max-w-7xl mx-auto">
|
||
<p className="text-sm text-red-600">{loadError}</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-slate-50 p-8">
|
||
<div className="max-w-7xl mx-auto">
|
||
|
||
{/* Header */}
|
||
<div className="flex items-center gap-3 mb-6">
|
||
<button
|
||
onClick={() => router.push('/')}
|
||
className="text-slate-400 hover:text-slate-600 text-sm transition-colors"
|
||
>
|
||
← Back
|
||
</button>
|
||
<h1 className="text-2xl font-bold text-slate-900">{firmName || '…'}</h1>
|
||
<span className="text-slate-300 text-2xl font-light">/</span>
|
||
<span className="text-2xl font-bold text-slate-400">Settings</span>
|
||
</div>
|
||
|
||
{/* Account Types */}
|
||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
|
||
Account Types
|
||
</h2>
|
||
<div className="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">
|
||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Prefix</th>
|
||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Account Size</th>
|
||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Profit Target</th>
|
||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Consistency</th>
|
||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Min Day P&L</th>
|
||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Min Trading Days</th>
|
||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Max Contracts</th>
|
||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">After First W/D</th>
|
||
<th className="px-3 py-3 w-32" />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.length === 0 && firmName && (
|
||
<tr>
|
||
<td colSpan={9} className="px-4 py-6 text-center text-sm text-slate-400 italic">
|
||
No account types yet
|
||
</td>
|
||
</tr>
|
||
)}
|
||
|
||
{rows.map((row) => (
|
||
<React.Fragment key={row.id}>
|
||
<tr className="border-b border-slate-100 last:border-0">
|
||
|
||
{/* Prefix */}
|
||
<td className="px-4 py-2.5">
|
||
<input
|
||
className={`${inputCls} font-mono w-32`}
|
||
value={row.prefix}
|
||
placeholder="e.g. MYACCT"
|
||
onChange={(e) => updateRow(row.id, { prefix: e.target.value })}
|
||
/>
|
||
</td>
|
||
|
||
{/* Account Size */}
|
||
<td className="px-4 py-2.5">
|
||
<div className="flex items-center gap-2">
|
||
<select
|
||
className={`${inputCls} w-24`}
|
||
value={row.useCustomSize ? 'custom' : row.accountSize}
|
||
onChange={(e) => {
|
||
if (e.target.value === 'custom') {
|
||
updateRow(row.id, { useCustomSize: true });
|
||
} else {
|
||
updateRow(row.id, {
|
||
accountSize: Number(e.target.value),
|
||
useCustomSize: false,
|
||
});
|
||
}
|
||
}}
|
||
>
|
||
{SIZE_PRESETS.map((s) => (
|
||
<option key={s} value={s}>{fmtSize(s)}</option>
|
||
))}
|
||
<option value="custom">Custom</option>
|
||
</select>
|
||
{row.useCustomSize && (
|
||
<input
|
||
type="number"
|
||
className={`${inputCls} w-28`}
|
||
value={row.accountSize || ''}
|
||
min={0}
|
||
step={1000}
|
||
placeholder="e.g. 35000"
|
||
onChange={(e) =>
|
||
updateRow(row.id, { accountSize: Number(e.target.value) })
|
||
}
|
||
/>
|
||
)}
|
||
</div>
|
||
</td>
|
||
|
||
{/* Profit Target */}
|
||
<td className="px-4 py-2.5">
|
||
<div className="relative flex items-center">
|
||
<span className="absolute left-2.5 text-slate-400 text-sm pointer-events-none">$</span>
|
||
<input
|
||
type="number"
|
||
className={`${inputCls} pl-5 w-28`}
|
||
value={row.profitTarget}
|
||
min={0}
|
||
step={100}
|
||
onChange={(e) =>
|
||
updateRow(row.id, { profitTarget: Number(e.target.value) })
|
||
}
|
||
/>
|
||
</div>
|
||
</td>
|
||
|
||
{/* Consistency */}
|
||
<td className="px-4 py-2.5">
|
||
<div className="relative flex items-center">
|
||
<input
|
||
type="number"
|
||
className={`${inputCls} pr-6 w-24`}
|
||
value={Math.round(row.consistency * 100)}
|
||
min={0}
|
||
max={100}
|
||
step={1}
|
||
onChange={(e) =>
|
||
updateRow(row.id, { consistency: Number(e.target.value) / 100 })
|
||
}
|
||
/>
|
||
<span className="absolute right-2.5 text-slate-400 text-sm pointer-events-none">%</span>
|
||
</div>
|
||
</td>
|
||
|
||
{/* Min Day P&L */}
|
||
<td className="px-4 py-2.5">
|
||
<div className="relative flex items-center">
|
||
<span className="absolute left-2.5 text-slate-400 text-sm pointer-events-none">$</span>
|
||
<input
|
||
type="number"
|
||
className={`${inputCls} pl-5 w-28`}
|
||
value={row.minDayPnL <= -999 ? '' : row.minDayPnL}
|
||
placeholder="None"
|
||
step={100}
|
||
onChange={(e) =>
|
||
updateRow(row.id, {
|
||
minDayPnL: e.target.value === '' ? -999 : Number(e.target.value),
|
||
})
|
||
}
|
||
/>
|
||
</div>
|
||
</td>
|
||
|
||
{/* Min Trading Days */}
|
||
<td className="px-4 py-2.5">
|
||
<input
|
||
type="number"
|
||
className={`${inputCls} w-20`}
|
||
value={row.minTradingDays}
|
||
min={0}
|
||
step={1}
|
||
onChange={(e) =>
|
||
updateRow(row.id, { minTradingDays: Number(e.target.value) })
|
||
}
|
||
/>
|
||
</td>
|
||
|
||
{/* Max Contracts */}
|
||
<td className="px-4 py-2.5">
|
||
<input
|
||
type="number"
|
||
className={`${inputCls} w-20`}
|
||
value={row.maxPositionSize === 0 ? '' : row.maxPositionSize}
|
||
placeholder="No limit"
|
||
min={0}
|
||
step={1}
|
||
onChange={(e) =>
|
||
updateRow(row.id, {
|
||
maxPositionSize: e.target.value === '' ? 0 : Number(e.target.value),
|
||
})
|
||
}
|
||
/>
|
||
</td>
|
||
|
||
{/* After Withdrawal */}
|
||
<td className="px-4 py-2.5">
|
||
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
||
<input
|
||
type="checkbox"
|
||
checked={row.targetSameEquity}
|
||
onChange={(e) =>
|
||
updateRow(row.id, { targetSameEquity: e.target.checked })
|
||
}
|
||
className="rounded border-slate-300 text-blue-500 focus:ring-blue-400"
|
||
/>
|
||
<span className="text-xs text-slate-600">Same equity</span>
|
||
</label>
|
||
</td>
|
||
|
||
{/* Actions */}
|
||
<td className="px-3 py-2.5">
|
||
<div className="flex items-center justify-end gap-1.5">
|
||
{row.error && (
|
||
<span className="text-xs text-red-500 font-medium mr-1">{row.error}</span>
|
||
)}
|
||
|
||
{row.confirmDelete ? (
|
||
<>
|
||
<span className="text-xs text-slate-500 mr-0.5">Delete?</span>
|
||
<button
|
||
onClick={() => deleteRow(row)}
|
||
className="px-2 py-1 bg-red-100 hover:bg-red-200 text-red-700 text-xs font-semibold rounded-md transition-colors"
|
||
>
|
||
Yes
|
||
</button>
|
||
<button
|
||
onClick={() => cancelDeleteRow(row.id)}
|
||
className="px-2 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 text-xs font-semibold rounded-md transition-colors"
|
||
>
|
||
No
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<button
|
||
onClick={() => saveRow(row)}
|
||
disabled={row.saving || !row.dirty}
|
||
className={`px-3 py-1 bg-blue-100 hover:bg-blue-200 disabled:opacity-50 text-blue-700 text-xs font-semibold rounded-md transition-colors ${!row.dirty ? 'invisible' : ''}`}
|
||
>
|
||
{row.saving ? '…' : 'Save'}
|
||
</button>
|
||
<button
|
||
onClick={() => confirmDeleteRow(row.id)}
|
||
className="p-1 text-slate-300 hover:text-red-400 hover:bg-red-50 rounded-md transition-colors"
|
||
title="Delete account type"
|
||
>
|
||
<TrashIcon />
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
|
||
{/* Stage sub-rows — shown when targetSameEquity is OFF */}
|
||
{!row.targetSameEquity && (
|
||
<>
|
||
{row.withdrawalStages.map((stage, idx) => {
|
||
const isLast = idx === row.withdrawalStages.length - 1;
|
||
const showPlus = isLast && row.withdrawalStages.length > 1;
|
||
return (
|
||
<tr key={`${row.id}-stage-${idx}`} className="bg-slate-50/60 border-b border-slate-100 last:border-0">
|
||
<td colSpan={9} className="pl-10 pr-4 py-1.5">
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-xs text-slate-400 w-16 shrink-0">
|
||
Stage {idx + 2}{showPlus ? '+' : ''}
|
||
</span>
|
||
<div className="relative flex items-center">
|
||
<span className="absolute left-2 text-slate-400 text-xs pointer-events-none">$</span>
|
||
<input
|
||
type="number"
|
||
className="border border-slate-200 rounded px-1.5 py-1 pl-4 text-xs text-slate-800 focus:outline-none focus:ring-1 focus:ring-blue-400 bg-white w-24"
|
||
value={stage.profit}
|
||
min={0}
|
||
step={100}
|
||
placeholder="Profit"
|
||
onChange={(e) => {
|
||
const next = row.withdrawalStages.map((s, i) =>
|
||
i === idx ? { ...s, profit: Number(e.target.value) } : s
|
||
);
|
||
updateRow(row.id, { withdrawalStages: next });
|
||
}}
|
||
/>
|
||
</div>
|
||
{row.consistency > 0 && (
|
||
<div className="relative flex items-center">
|
||
<input
|
||
type="number"
|
||
className="border border-slate-200 rounded px-1.5 py-1 pr-5 text-xs text-slate-800 focus:outline-none focus:ring-1 focus:ring-blue-400 bg-white w-20"
|
||
value={Math.round(stage.consistency * 100)}
|
||
min={0}
|
||
max={100}
|
||
step={1}
|
||
placeholder="Cons%"
|
||
onChange={(e) => {
|
||
const next = row.withdrawalStages.map((s, i) =>
|
||
i === idx ? { ...s, consistency: Number(e.target.value) / 100 } : s
|
||
);
|
||
updateRow(row.id, { withdrawalStages: next });
|
||
}}
|
||
/>
|
||
<span className="absolute right-1.5 text-slate-400 text-xs pointer-events-none">%</span>
|
||
</div>
|
||
)}
|
||
<div className="flex items-center gap-1">
|
||
<input
|
||
type="number"
|
||
className="border border-slate-200 rounded px-1.5 py-1 text-xs text-slate-800 focus:outline-none focus:ring-1 focus:ring-blue-400 bg-white w-12"
|
||
value={stage.minTradingDays}
|
||
min={0}
|
||
step={1}
|
||
placeholder="Days"
|
||
onChange={(e) => {
|
||
const next = row.withdrawalStages.map((s, i) =>
|
||
i === idx ? { ...s, minTradingDays: Number(e.target.value) } : s
|
||
);
|
||
updateRow(row.id, { withdrawalStages: next });
|
||
}}
|
||
/>
|
||
<span className="text-slate-400 text-[10px] shrink-0">days</span>
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
const next = row.withdrawalStages.filter((_, i) => i !== idx);
|
||
updateRow(row.id, { withdrawalStages: next });
|
||
}}
|
||
className="text-slate-300 hover:text-red-400 text-sm leading-none"
|
||
title="Remove stage"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
<tr className="bg-slate-50/60 border-b border-dashed border-slate-200">
|
||
<td colSpan={9} className="pl-10 pr-4 py-1">
|
||
<button
|
||
onClick={() =>
|
||
updateRow(row.id, {
|
||
withdrawalStages: [
|
||
...row.withdrawalStages,
|
||
{ profit: row.profitTarget, consistency: row.consistency, minTradingDays: row.minTradingDays },
|
||
],
|
||
})
|
||
}
|
||
className="text-xs text-blue-400 hover:text-blue-600"
|
||
>
|
||
+ Add stage
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</>
|
||
)}
|
||
</React.Fragment>
|
||
))}
|
||
|
||
{/* Add row */}
|
||
<tr className="border-t border-dashed border-slate-200">
|
||
<td colSpan={9} className="px-2 py-2">
|
||
<button
|
||
onClick={() => setRows((prev) => [...prev, newRow()])}
|
||
className="w-full py-1.5 text-sm text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-lg transition-colors"
|
||
>
|
||
+ Add account type
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* ── Instruments ── */}
|
||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mt-10 mb-3">
|
||
Instruments
|
||
</h2>
|
||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
|
||
{instruments.length === 0 ? (
|
||
<div className="px-4 py-8 text-center text-sm text-slate-400 italic">Loading…</div>
|
||
) : (
|
||
<table className="w-full text-sm border-collapse">
|
||
<thead>
|
||
<tr className="border-b border-slate-200 bg-slate-50">
|
||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Symbol</th>
|
||
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-slate-400">Banned</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{instruments.map((sym) => {
|
||
const banned = bannedSymbols.has(sym);
|
||
return (
|
||
<tr key={sym} className="border-b border-slate-100 last:border-0">
|
||
<td className="px-4 py-2.5 flex items-center gap-2">
|
||
<span className="font-mono font-semibold text-slate-800">{sym}</span>
|
||
{banned && (
|
||
<span className="text-xs bg-red-100 text-red-700 px-1.5 py-0.5 rounded font-semibold">
|
||
{sym} BANNED
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-2.5 text-right">
|
||
<button
|
||
onClick={() => toggleBan(sym)}
|
||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${
|
||
banned ? 'bg-red-500' : 'bg-slate-200'
|
||
}`}
|
||
>
|
||
<span
|
||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
|
||
banned ? 'translate-x-4' : 'translate-x-1'
|
||
}`}
|
||
/>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|