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:
@@ -3,7 +3,7 @@
|
||||
* Requirements: 13.1, 13.2
|
||||
*/
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiGet, apiPost, apiPut } from './client';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from './client';
|
||||
import type { ApiBase } from './client';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -444,6 +444,50 @@ export function useActiveLockouts() {
|
||||
return useGet<Lockout[]>(['lockouts'], 'query', '/api/admin/trading/lockouts');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin: Approval Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ApprovalConfig {
|
||||
auto_approve_paper: boolean;
|
||||
require_approval_for_live: boolean;
|
||||
approval_timeout_minutes: number;
|
||||
}
|
||||
|
||||
export function useApprovalConfig() {
|
||||
return useGet<ApprovalConfig>(['approval-config'], 'query', '/api/admin/trading/approval-config');
|
||||
}
|
||||
|
||||
export function useUpdateApprovalConfig() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: Partial<ApprovalConfig>) =>
|
||||
apiPut<ApprovalConfig>('query', '/api/admin/trading/approval-config', body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['approval-config'] });
|
||||
qc.invalidateQueries({ queryKey: ['trading-config'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateLockout() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { ticker: string; reason: string; duration_minutes: number }) =>
|
||||
apiPost<Lockout>('query', '/api/admin/trading/lockouts', body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['lockouts'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteLockout() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiDelete<unknown>('query', `/api/admin/trading/lockouts/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['lockouts'] }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin: Sources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -92,8 +92,18 @@ export const handlers = [
|
||||
http.get('/api/orders/:id', () => HttpResponse.json({ ...mockOrders[0], idempotency_key: null, decision_trace: null, events: [], audit_trail: [] })),
|
||||
http.get('/api/positions', () => HttpResponse.json(mockPositions)),
|
||||
http.get('/api/admin/trading/config', () => HttpResponse.json({ trading_mode: 'paper', config: {} })),
|
||||
http.get('/api/admin/trading/approval-config', () => HttpResponse.json({ auto_approve_paper: true, require_approval_for_live: true, approval_timeout_minutes: 5 })),
|
||||
http.put('/api/admin/trading/approval-config', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ auto_approve_paper: true, require_approval_for_live: true, approval_timeout_minutes: 5, ...body });
|
||||
}),
|
||||
http.get('/api/admin/trading/approvals', () => HttpResponse.json([])),
|
||||
http.get('/api/admin/trading/lockouts', () => HttpResponse.json([])),
|
||||
http.post('/api/admin/trading/lockouts', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ id: 'lockout-new', ticker: body.ticker, reason: body.reason, lockout_type: body.lockout_type ?? 'manual', expires_at: new Date(Date.now() + ((body.duration_minutes as number) ?? 60) * 60000).toISOString(), created_at: new Date().toISOString() }, { status: 201 });
|
||||
}),
|
||||
http.delete('/api/admin/trading/lockouts/:id', () => HttpResponse.json({ status: 'deleted' })),
|
||||
http.get('/api/ops/pipeline/health', () => HttpResponse.json({ hours: 24, document_stages: [{ status: 'extracted', doc_count: 5 }], parsing: {}, extraction: {}, aggregation: {} })),
|
||||
http.get('/api/ops/ingestion/summary', () => HttpResponse.json({ total_runs: 10, completed: 8, failed: 2, total_items_fetched: 50, total_items_new: 12, by_source_type: [] })),
|
||||
http.get('/api/ops/ingestion/throughput', () => HttpResponse.json([])),
|
||||
|
||||
Reference in New Issue
Block a user