fix(plans): harden holding selection and deep links
This commit is contained in:
parent
36bd21b7cd
commit
9b6cbc1e67
@ -386,7 +386,7 @@ describe('PositionsTab DOM behavior', () => {
|
|||||||
render(<PositionsTab botState={buildBotState(now)} onManageHolding={onManageHolding} />);
|
render(<PositionsTab botState={buildBotState(now)} onManageHolding={onManageHolding} />);
|
||||||
|
|
||||||
await user.click(await screen.findByRole('button', { name: 'High Risk Scalper' }));
|
await user.click(await screen.findByRole('button', { name: 'High Risk Scalper' }));
|
||||||
const manageButtons = await screen.findAllByRole('button', { name: /Open Plan|Manage in Plans/ });
|
const manageButtons = await screen.findAllByRole('button', { name: 'Open Plan' });
|
||||||
await user.click(manageButtons[0]);
|
await user.click(manageButtons[0]);
|
||||||
|
|
||||||
expect(onManageHolding).toHaveBeenCalledWith(expect.objectContaining({
|
expect(onManageHolding).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
@ -394,7 +394,46 @@ describe('PositionsTab DOM behavior', () => {
|
|||||||
profileId: 'p1',
|
profileId: 'p1',
|
||||||
tradeId: 'TRD-POS-1',
|
tradeId: 'TRD-POS-1',
|
||||||
planEntryId: 'simple-setup-1'
|
planEntryId: 'simple-setup-1'
|
||||||
}));
|
}), 'open-plan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a create-exit-plan action for unmanaged live holdings', async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
fetchPositionsBootstrapMock.mockResolvedValue({
|
||||||
|
entries: [],
|
||||||
|
orders: [{
|
||||||
|
id: 'entry-order',
|
||||||
|
order_id: 'entry-order',
|
||||||
|
profile_id: 'p1',
|
||||||
|
symbol: 'BTC/USDT',
|
||||||
|
type: 'Market',
|
||||||
|
side: 'BUY',
|
||||||
|
qty: 1,
|
||||||
|
price: 100,
|
||||||
|
status: 'filled',
|
||||||
|
timestamp: now - 1_000,
|
||||||
|
created_at: new Date(now - 1_000).toISOString(),
|
||||||
|
trade_id: 'TRD-POS-1',
|
||||||
|
action: 'ENTRY',
|
||||||
|
source: 'BOT'
|
||||||
|
}],
|
||||||
|
historyTradeKeys: [],
|
||||||
|
profiles: [{ id: 'p1', name: 'High Risk Scalper' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const onManageHolding = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<PositionsTab botState={buildBotState(now)} onManageHolding={onManageHolding} />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'High Risk Scalper' }));
|
||||||
|
const manageButtons = await screen.findAllByRole('button', { name: 'Create Exit Plan' });
|
||||||
|
await user.click(manageButtons[0]);
|
||||||
|
|
||||||
|
expect(onManageHolding).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
symbol: 'BTC/USDT',
|
||||||
|
profileId: 'p1',
|
||||||
|
tradeId: 'TRD-POS-1',
|
||||||
|
}), 'create-exit-plan');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles non-admin bootstrap failures with empty-state fallback', async () => {
|
it('handles non-admin bootstrap failures with empty-state fallback', async () => {
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { fetchPositionsBootstrap } from '../lib/positionsApi';
|
|||||||
|
|
||||||
interface PositionsTabProps {
|
interface PositionsTabProps {
|
||||||
botState: BotState;
|
botState: BotState;
|
||||||
onManageHolding?: (position: HybridPosition) => void;
|
onManageHolding?: (position: HybridPosition, action: 'open-plan' | 'create-exit-plan') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HybridPosition {
|
interface HybridPosition {
|
||||||
@ -1453,10 +1453,10 @@ export const PositionsTab = ({ botState, onManageHolding }: PositionsTabProps) =
|
|||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
{pos.source === 'BOT' && pos.profileId && pos.tradeId && onManageHolding && (
|
{pos.source === 'BOT' && pos.profileId && pos.tradeId && onManageHolding && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onManageHolding(pos)}
|
onClick={() => onManageHolding(pos, pos.planEntryId ? 'open-plan' : 'create-exit-plan')}
|
||||||
className="px-2 py-1 bg-sky-500/10 hover:bg-sky-500/20 text-sky-300 border border-sky-500/20 rounded text-[10px] font-bold uppercase transition-colors"
|
className="px-2 py-1 bg-sky-500/10 hover:bg-sky-500/20 text-sky-300 border border-sky-500/20 rounded text-[10px] font-bold uppercase transition-colors"
|
||||||
>
|
>
|
||||||
{(pos.planEntryId || pos.planMode) ? 'Open Plan' : 'Manage in Plans'}
|
{pos.planEntryId ? 'Open Plan' : 'Create Exit Plan'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{pos.source === 'BOT' && (
|
{pos.source === 'BOT' && (
|
||||||
|
|||||||
@ -36,14 +36,14 @@ export function PortfolioView() {
|
|||||||
{tab === 'Positions & Orders' && (
|
{tab === 'Positions & Orders' && (
|
||||||
<PositionsTab
|
<PositionsTab
|
||||||
botState={botState}
|
botState={botState}
|
||||||
onManageHolding={(position) => {
|
onManageHolding={(position, action) => {
|
||||||
const params = position.planEntryId
|
const params = action === 'open-plan' && position.planEntryId
|
||||||
? new URLSearchParams({ setupId: position.planEntryId })
|
? new URLSearchParams({ setupId: position.planEntryId })
|
||||||
: new URLSearchParams({
|
: new URLSearchParams({
|
||||||
mode: 'sell',
|
mode: 'sell',
|
||||||
symbol: position.symbol,
|
symbol: position.symbol,
|
||||||
});
|
});
|
||||||
if (!position.planEntryId && position.tradeId) params.set('tradeId', position.tradeId);
|
if (action !== 'open-plan' && position.tradeId) params.set('tradeId', position.tradeId);
|
||||||
navigate(`/simple?${params.toString()}`);
|
navigate(`/simple?${params.toString()}`);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -631,9 +631,10 @@ export function SimpleView() {
|
|||||||
const [selectedHoldingTradeId, setSelectedHoldingTradeId] = useState<string | null>(null);
|
const [selectedHoldingTradeId, setSelectedHoldingTradeId] = useState<string | null>(null);
|
||||||
const [focusedSetupId, setFocusedSetupId] = useState<string | null>(null);
|
const [focusedSetupId, setFocusedSetupId] = useState<string | null>(null);
|
||||||
const marketPriceRequestSymbolRef = useRef<string>('');
|
const marketPriceRequestSymbolRef = useRef<string>('');
|
||||||
const consumedPrefillRef = useRef(false);
|
const consumedPrefillKeyRef = useRef<string>('');
|
||||||
const consumedSetupFocusRef = useRef(false);
|
const consumedSetupFocusKeyRef = useRef<string>('');
|
||||||
const setupCardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
const setupCardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||||
|
const focusedSetupTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const normalizedSymbol = draft.symbol.trim().toUpperCase();
|
const normalizedSymbol = draft.symbol.trim().toUpperCase();
|
||||||
const symbolState = botState?.symbols && typeof botState.symbols === 'object' ? botState.symbols : {};
|
const symbolState = botState?.symbols && typeof botState.symbols === 'object' ? botState.symbols : {};
|
||||||
@ -804,10 +805,11 @@ export function SimpleView() {
|
|||||||
}, [draft.side, selectedSellHolding, availableSellHoldings]);
|
}, [draft.side, selectedSellHolding, availableSellHoldings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (consumedPrefillRef.current) return;
|
|
||||||
const requestedMode = String(searchParams.get('mode') || '').trim().toLowerCase();
|
const requestedMode = String(searchParams.get('mode') || '').trim().toLowerCase();
|
||||||
const requestedSymbol = String(searchParams.get('symbol') || '').trim().toUpperCase();
|
const requestedSymbol = String(searchParams.get('symbol') || '').trim().toUpperCase();
|
||||||
const requestedTradeId = String(searchParams.get('tradeId') || '').trim();
|
const requestedTradeId = String(searchParams.get('tradeId') || '').trim();
|
||||||
|
const prefillKey = `${requestedMode}|${requestedSymbol}|${requestedTradeId}`;
|
||||||
|
if (!requestedMode || consumedPrefillKeyRef.current === prefillKey) return;
|
||||||
if (requestedMode !== 'sell') return;
|
if (requestedMode !== 'sell') return;
|
||||||
if (availableSellHoldings.length === 0) return;
|
if (availableSellHoldings.length === 0) return;
|
||||||
|
|
||||||
@ -824,18 +826,18 @@ export function SimpleView() {
|
|||||||
setMessage(`Loaded ${selected.symbol} from Portfolio. Configure the profit target and save the plan.`);
|
setMessage(`Loaded ${selected.symbol} from Portfolio. Configure the profit target and save the plan.`);
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
consumedPrefillRef.current = true;
|
consumedPrefillKeyRef.current = prefillKey;
|
||||||
setSearchParams({}, { replace: true });
|
setSearchParams({}, { replace: true });
|
||||||
}, [availableSellHoldings, searchParams, setSearchParams]);
|
}, [availableSellHoldings, searchParams, setSearchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (consumedSetupFocusRef.current) return;
|
|
||||||
const requestedSetupId = String(searchParams.get('setupId') || '').trim();
|
const requestedSetupId = String(searchParams.get('setupId') || '').trim();
|
||||||
|
if (!requestedSetupId || consumedSetupFocusKeyRef.current === requestedSetupId) return;
|
||||||
if (!requestedSetupId) return;
|
if (!requestedSetupId) return;
|
||||||
if (savedSetups.length === 0) return;
|
if (savedSetups.length === 0) return;
|
||||||
|
|
||||||
const targetEntry = savedSetups.find((entry) => String(entry.stock_instance_id || '') === requestedSetupId) || null;
|
const targetEntry = savedSetups.find((entry) => String(entry.stock_instance_id || '') === requestedSetupId) || null;
|
||||||
consumedSetupFocusRef.current = true;
|
consumedSetupFocusKeyRef.current = requestedSetupId;
|
||||||
setSearchParams({}, { replace: true });
|
setSearchParams({}, { replace: true });
|
||||||
|
|
||||||
if (!targetEntry) return;
|
if (!targetEntry) return;
|
||||||
@ -843,12 +845,26 @@ export function SimpleView() {
|
|||||||
setFocusedSetupId(requestedSetupId);
|
setFocusedSetupId(requestedSetupId);
|
||||||
setMessage(`Focused saved plan for ${targetEntry.symbol}.`);
|
setMessage(`Focused saved plan for ${targetEntry.symbol}.`);
|
||||||
setError(null);
|
setError(null);
|
||||||
window.setTimeout(() => setFocusedSetupId((prev) => (prev === requestedSetupId ? null : prev)), 2200);
|
if (focusedSetupTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(focusedSetupTimerRef.current);
|
||||||
|
}
|
||||||
|
focusedSetupTimerRef.current = window.setTimeout(() => {
|
||||||
|
setFocusedSetupId((prev) => (prev === requestedSetupId ? null : prev));
|
||||||
|
focusedSetupTimerRef.current = null;
|
||||||
|
}, 2200);
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
setupCardRefs.current[requestedSetupId]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
setupCardRefs.current[requestedSetupId]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
});
|
});
|
||||||
}, [savedSetups, searchParams, setSearchParams]);
|
}, [savedSetups, searchParams, setSearchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (focusedSetupTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(focusedSetupTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function copyIdentifier(kind: 'trade' | 'order', value: string | null | undefined) {
|
async function copyIdentifier(kind: 'trade' | 'order', value: string | null | undefined) {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
try {
|
try {
|
||||||
@ -960,6 +976,7 @@ export function SimpleView() {
|
|||||||
|
|
||||||
await refreshSetupList();
|
await refreshSetupList();
|
||||||
setEditingSetupId(null);
|
setEditingSetupId(null);
|
||||||
|
setSelectedHoldingTradeId(null);
|
||||||
setMarketPriceSource(draft.currentMarketPrice ? marketPriceSource : null);
|
setMarketPriceSource(draft.currentMarketPrice ? marketPriceSource : null);
|
||||||
setDraft({
|
setDraft({
|
||||||
...DEFAULT_DRAFT,
|
...DEFAULT_DRAFT,
|
||||||
@ -974,6 +991,7 @@ export function SimpleView() {
|
|||||||
|
|
||||||
function handleEdit(entry: ManualEntryPayload) {
|
function handleEdit(entry: ManualEntryPayload) {
|
||||||
setEditingSetupId(String(entry.stock_instance_id || ''));
|
setEditingSetupId(String(entry.stock_instance_id || ''));
|
||||||
|
setSelectedHoldingTradeId(String(entry.linked_trade_id || '').trim() || null);
|
||||||
setMarketPriceSource(inferMarketPriceSourceFromEntry(entry));
|
setMarketPriceSource(inferMarketPriceSourceFromEntry(entry));
|
||||||
setDraft(buildDraftFromEntry(entry));
|
setDraft(buildDraftFromEntry(entry));
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
@ -986,6 +1004,7 @@ export function SimpleView() {
|
|||||||
await deleteManualEntry(entryId);
|
await deleteManualEntry(entryId);
|
||||||
if (editingSetupId === entryId) {
|
if (editingSetupId === entryId) {
|
||||||
setEditingSetupId(null);
|
setEditingSetupId(null);
|
||||||
|
setSelectedHoldingTradeId(null);
|
||||||
setMarketPriceSource(null);
|
setMarketPriceSource(null);
|
||||||
setDraft(DEFAULT_DRAFT);
|
setDraft(DEFAULT_DRAFT);
|
||||||
}
|
}
|
||||||
@ -1149,6 +1168,9 @@ export function SimpleView() {
|
|||||||
value={draft.symbol}
|
value={draft.symbol}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setMarketPriceSource(null);
|
setMarketPriceSource(null);
|
||||||
|
if (draft.side === 'sell') {
|
||||||
|
setSelectedHoldingTradeId(null);
|
||||||
|
}
|
||||||
setDraft((prev) => ({
|
setDraft((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
symbol: e.target.value.toUpperCase(),
|
symbol: e.target.value.toUpperCase(),
|
||||||
@ -1179,6 +1201,9 @@ export function SimpleView() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMarketPriceSource(null);
|
setMarketPriceSource(null);
|
||||||
|
if (draft.side === 'sell') {
|
||||||
|
setSelectedHoldingTradeId(null);
|
||||||
|
}
|
||||||
setDraft((prev) => ({
|
setDraft((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
symbol,
|
symbol,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user