fix: operator approval workflow — add approval toggle, lockout CRUD, and PBT tests

- Add GET/PUT /api/admin/trading/approval-config endpoints
- Add POST/DELETE /api/admin/trading/lockouts endpoints
- Add useApprovalConfig, useUpdateApprovalConfig, useCreateLockout, useDeleteLockout hooks
- Add Paper Order Approval toggle card with confirmation dialog
- Add lockout creation form and delete button to Active Lockouts card
- Add MSW handlers for all new endpoints
- Add property-based tests for bug condition exploration and preservation
This commit is contained in:
Celes Renata
2026-04-17 06:14:46 +00:00
parent 3b7ded37cc
commit b149f70507
9 changed files with 1035 additions and 5 deletions
+144 -3
View File
@@ -5,10 +5,14 @@ import {
usePendingApprovals,
useReviewApproval,
useActiveLockouts,
useCreateLockout,
useDeleteLockout,
useMacroStatus,
useToggleMacro,
useCompetitiveStatus,
useToggleCompetitive,
useApprovalConfig,
useUpdateApprovalConfig,
} from '../api/hooks';
import { useResetPaperTrading, useTradingStatus, useUpdateTradingConfig } from '../api/tradingHooks';
import { StatusBadge, LoadingSpinner, Card } from '../components/ui';
@@ -26,13 +30,23 @@ export function TradingPage() {
const reviewApproval = useReviewApproval();
const toggleMacro = useToggleMacro();
const toggleCompetitive = useToggleCompetitive();
const { data: approvalConfig } = useApprovalConfig();
const updateApprovalConfig = useUpdateApprovalConfig();
const createLockout = useCreateLockout();
const deleteLockout = useDeleteLockout();
const [lockoutTicker, setLockoutTicker] = useState('');
const [lockoutReason, setLockoutReason] = useState('');
const [lockoutDuration, setLockoutDuration] = useState(60);
// Normalize API field names (API returns macro_enabled/competitive_enabled, not enabled)
const macroEnabled = macroStatus?.enabled ?? macroStatus?.macro_enabled ?? false;
const competitiveEnabled = competitiveStatus?.enabled ?? competitiveStatus?.competitive_enabled ?? false;
const autoApprovePaper = approvalConfig?.auto_approve_paper ?? true;
const [confirmMode, setConfirmMode] = useState<string | null>(null);
const [confirmMacroToggle, setConfirmMacroToggle] = useState(false);
const [confirmCompetitiveToggle, setConfirmCompetitiveToggle] = useState(false);
const [confirmApprovalToggle, setConfirmApprovalToggle] = useState(false);
if (configLoading) return <LoadingSpinner />;
@@ -252,6 +266,60 @@ export function TradingPage() {
)}
</Card>
{/* Paper Order Approval Toggle */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Paper Order Approval</h2>
<div className="flex items-center gap-4">
<button
onClick={() => setConfirmApprovalToggle(true)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-surface-900 ${
!autoApprovePaper ? 'bg-brand-600' : 'bg-surface-700'
}`}
role="switch"
aria-checked={!autoApprovePaper}
aria-label="Toggle paper order approval requirement"
>
<span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform ${
!autoApprovePaper ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
<span className="text-sm text-gray-300">
{autoApprovePaper ? 'Auto-approve paper orders' : 'Require approval for paper orders'}
</span>
</div>
{/* Confirmation dialog for approval toggle */}
{confirmApprovalToggle && (
<div className="mt-4 rounded-lg border border-orange-700/50 bg-orange-900/20 p-4">
<p className="text-sm text-orange-300">
Are you sure you want to {autoApprovePaper ? 'require approval for' : 'auto-approve'} paper orders?
{autoApprovePaper
? ' Enabling approval requirements will hold all paper orders for manual review before execution.'
: ' Disabling approval requirements will allow paper orders to execute automatically.'}
</p>
<div className="mt-3 flex gap-2">
<button
onClick={() => {
updateApprovalConfig.mutate({ auto_approve_paper: !autoApprovePaper });
setConfirmApprovalToggle(false);
}}
className="rounded-md bg-orange-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-orange-700"
>
Confirm
</button>
<button
onClick={() => setConfirmApprovalToggle(false)}
className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800"
>
Cancel
</button>
</div>
</div>
)}
</Card>
{/* Pending Approvals */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">
@@ -273,6 +341,70 @@ export function TradingPage() {
<h2 className="mb-3 text-sm font-medium text-gray-400">
Active Lockouts ({lockouts?.length ?? 0})
</h2>
{/* Lockout Creation Form */}
<form
className="mb-4 flex flex-wrap items-end gap-2 rounded border border-surface-700 bg-surface-900 p-3"
onSubmit={(e) => {
e.preventDefault();
if (!lockoutTicker.trim()) return;
createLockout.mutate(
{ ticker: lockoutTicker.trim().toUpperCase(), reason: lockoutReason.trim(), duration_minutes: lockoutDuration },
{
onSuccess: () => {
setLockoutTicker('');
setLockoutReason('');
setLockoutDuration(60);
},
},
);
}}
>
<div className="flex flex-col gap-1">
<label htmlFor="lockout-ticker" className="text-xs text-gray-500">Ticker</label>
<input
id="lockout-ticker"
type="text"
placeholder="AAPL"
value={lockoutTicker}
onChange={(e) => setLockoutTicker(e.target.value.toUpperCase())}
className="w-24 rounded-md border border-surface-700 bg-surface-950 px-2 py-1 text-xs font-mono text-gray-200 uppercase placeholder-gray-600"
required
/>
</div>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor="lockout-reason" className="text-xs text-gray-500">Reason</label>
<input
id="lockout-reason"
type="text"
placeholder="Manual lockout reason…"
value={lockoutReason}
onChange={(e) => setLockoutReason(e.target.value)}
className="rounded-md border border-surface-700 bg-surface-950 px-2 py-1 text-xs text-gray-200 placeholder-gray-600"
required
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="lockout-duration" className="text-xs text-gray-500">Minutes</label>
<input
id="lockout-duration"
type="number"
min={1}
value={lockoutDuration}
onChange={(e) => setLockoutDuration(Number(e.target.value))}
className="w-20 rounded-md border border-surface-700 bg-surface-950 px-2 py-1 text-xs text-gray-200"
required
/>
</div>
<button
type="submit"
disabled={createLockout.isPending}
className="rounded-md bg-brand-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{createLockout.isPending ? 'Creating…' : 'Add Lockout'}
</button>
</form>
{!lockouts?.length ? (
<p className="text-sm text-gray-500">No active lockouts</p>
) : (
@@ -286,9 +418,18 @@ export function TradingPage() {
<StatusBadge status={l.lockout_type} />
<span className="text-sm text-gray-400">{l.reason}</span>
</div>
<span className="text-xs text-gray-500">
{expiresIn > 0 ? `${expiresIn}m remaining` : 'expired'}
</span>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">
{expiresIn > 0 ? `${expiresIn}m remaining` : 'expired'}
</span>
<button
onClick={() => deleteLockout.mutate(l.id)}
disabled={deleteLockout.isPending}
className="rounded bg-red-700/80 px-2 py-0.5 text-xs font-medium text-red-100 hover:bg-red-600 disabled:opacity-50"
>
Remove
</button>
</div>
</div>
);
})}