1628 lines
68 KiB
TypeScript
1628 lines
68 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import type { FormEvent } from 'react';
|
|
import { Pencil, RefreshCw, Trash2 } from 'lucide-react';
|
|
import { useSearchParams } from 'react-router-dom';
|
|
import { useAppContext } from '../context/AppContext';
|
|
import { fetchChartBars, fetchResearchProfile } from '../lib/marketApi';
|
|
import {
|
|
createManualEntry,
|
|
deleteManualEntry,
|
|
fetchManualEntries,
|
|
updateManualEntry,
|
|
type ManualEntryPayload,
|
|
} from '../lib/manualEntriesApi';
|
|
import { fetchTradeProfiles, type TradeProfilePayload } from '../lib/profileApi';
|
|
import { Button } from '../components/ui/button';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
|
|
import { Input } from '../components/ui/input';
|
|
import { PageHeader } from '../components/ui/page-header';
|
|
import { Select } from '../components/ui/select';
|
|
|
|
type SimpleSide = 'buy' | 'sell';
|
|
type TriggerMode = 'dollar' | 'percent';
|
|
|
|
type SimpleSetupDraft = {
|
|
symbol: string;
|
|
side: SimpleSide;
|
|
sizingMode: 'quantity' | 'amount';
|
|
quantity: string;
|
|
amountUsd: string;
|
|
currentMarketPrice: string;
|
|
dropMode: TriggerMode;
|
|
dropValue: string;
|
|
profitMode: TriggerMode;
|
|
profitValue: string;
|
|
notes: string;
|
|
};
|
|
|
|
type SimpleHolding = {
|
|
symbol: string;
|
|
size: number;
|
|
entryPrice: number;
|
|
profileId?: string;
|
|
tradeId?: string;
|
|
};
|
|
|
|
type SimpleRuntimeSnapshot = {
|
|
stage: 'armed' | 'entry_submitted' | 'filled' | 'exit_submitted' | 'closed' | 'unknown';
|
|
label: string;
|
|
tone: 'neutral' | 'info' | 'success';
|
|
tradeId?: string;
|
|
orderId?: string;
|
|
};
|
|
|
|
type SimpleOperationalEvent = NonNullable<ReturnType<typeof useAppContext>['botState']['operationalEvents']>[number];
|
|
|
|
type MarketPriceSource = 'live' | 'latest_close' | 'reference_price' | null;
|
|
|
|
const SIMPLE_AUTO_PROFILE_NAME = 'Simple Auto Profile';
|
|
const SIMPLE_AUTO_PROFILE_KEY = SIMPLE_AUTO_PROFILE_NAME.toLowerCase();
|
|
const SIMPLE_SYMBOL_DATALIST_ID = 'simple-supported-symbols';
|
|
const COMMON_SIMPLE_SYMBOLS = [
|
|
'AAPL',
|
|
'MSFT',
|
|
'NVDA',
|
|
'TSLA',
|
|
'SPY',
|
|
'QQQ',
|
|
'AMZN',
|
|
'META',
|
|
'BTC/USD',
|
|
'ETH/USD',
|
|
'SOL/USD',
|
|
];
|
|
|
|
const DEFAULT_DRAFT: SimpleSetupDraft = {
|
|
symbol: '',
|
|
side: 'buy',
|
|
sizingMode: 'quantity',
|
|
quantity: '',
|
|
amountUsd: '',
|
|
currentMarketPrice: '',
|
|
dropMode: 'percent',
|
|
dropValue: '',
|
|
profitMode: 'percent',
|
|
profitValue: '',
|
|
notes: '',
|
|
};
|
|
|
|
function parsePositiveNumber(value: string): number | null {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
const parsed = Number(trimmed);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
}
|
|
|
|
function parseNonNegativeNumber(value: string): number | null {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) return null;
|
|
const parsed = Number(trimmed);
|
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
}
|
|
|
|
function roundPrice(value: number): number {
|
|
return Number(value.toFixed(4));
|
|
}
|
|
|
|
function matchesSimpleAutoProfile(profile: Pick<TradeProfilePayload, 'name'> | null | undefined) {
|
|
return String(profile?.name || '').trim().toLowerCase() === SIMPLE_AUTO_PROFILE_KEY;
|
|
}
|
|
|
|
function normalizeSetupSide(value: unknown): SimpleSide {
|
|
return String(value || '').trim().toLowerCase() === 'sell' ? 'sell' : 'buy';
|
|
}
|
|
|
|
function normalizeMode(value: unknown, fallback: TriggerMode = 'percent'): TriggerMode {
|
|
return String(value || '').trim().toLowerCase() === 'dollar' ? 'dollar' : fallback;
|
|
}
|
|
|
|
function normalizeHoldingMode(value: unknown): 'short_term' | 'long_term' {
|
|
return String(value || '').trim().toLowerCase() === 'long_term' ? 'long_term' : 'short_term';
|
|
}
|
|
|
|
function normalizeAutomationState(value: unknown, entry: ManualEntryPayload): string {
|
|
const explicit = String(value || '').trim().toLowerCase();
|
|
if (explicit) return explicit;
|
|
const status = String(entry.status || '').trim().toLowerCase();
|
|
const holdingMode = normalizeHoldingMode(entry.holding_mode);
|
|
if (holdingMode === 'long_term') {
|
|
return status === 'sellcompleted' ? 'closed' : 'paused_long_term';
|
|
}
|
|
switch (status) {
|
|
case 'simple_armed_buy':
|
|
case 'simple_armed_sell':
|
|
return 'armed';
|
|
case 'simple_entry_submitted':
|
|
return 'entry_submitted';
|
|
case 'simple_bought':
|
|
return 'holding_managed';
|
|
case 'simple_exit_submitted':
|
|
return 'exit_submitted';
|
|
case 'sellcompleted':
|
|
return 'closed';
|
|
default:
|
|
return 'unknown';
|
|
}
|
|
}
|
|
|
|
function computeBuyTriggerPrice(draft: SimpleSetupDraft): number | null {
|
|
const currentMarketPrice = parsePositiveNumber(draft.currentMarketPrice);
|
|
const dropValue = parseNonNegativeNumber(draft.dropValue);
|
|
if (!currentMarketPrice || dropValue === null) return null;
|
|
|
|
if (draft.dropMode === 'dollar') {
|
|
const trigger = currentMarketPrice - dropValue;
|
|
return trigger > 0 ? roundPrice(trigger) : null;
|
|
}
|
|
|
|
const trigger = currentMarketPrice * (1 - dropValue / 100);
|
|
return trigger > 0 ? roundPrice(trigger) : null;
|
|
}
|
|
|
|
function computeProfitTargetPrice(entryPrice: number | null, mode: TriggerMode, value: string): number | null {
|
|
const numericEntryPrice = entryPrice && entryPrice > 0 ? entryPrice : null;
|
|
const targetValue = parsePositiveNumber(value);
|
|
if (!numericEntryPrice || !targetValue) return null;
|
|
|
|
if (mode === 'dollar') {
|
|
return roundPrice(numericEntryPrice + targetValue);
|
|
}
|
|
|
|
return roundPrice(numericEntryPrice * (1 + targetValue / 100));
|
|
}
|
|
|
|
export function buildSimpleSetupPayload(input: {
|
|
draft: SimpleSetupDraft;
|
|
existingId?: string;
|
|
holding?: SimpleHolding | null;
|
|
existingEntry?: ManualEntryPayload | null;
|
|
}): ManualEntryPayload {
|
|
const symbol = input.draft.symbol.trim().toUpperCase();
|
|
if (!symbol) {
|
|
throw new Error('Symbol is required');
|
|
}
|
|
|
|
const currentMarketPrice = parsePositiveNumber(input.draft.currentMarketPrice);
|
|
if (!currentMarketPrice) {
|
|
throw new Error('No recent market price is available right now. The app uses live price when available, otherwise the latest close.');
|
|
}
|
|
|
|
const quantity = parsePositiveNumber(input.draft.quantity);
|
|
const amountUsd = parsePositiveNumber(input.draft.amountUsd);
|
|
|
|
const profitValue = parsePositiveNumber(input.draft.profitValue);
|
|
if (!profitValue) {
|
|
throw new Error('Profit target is required');
|
|
}
|
|
|
|
const side = input.draft.side;
|
|
const holding = input.holding || null;
|
|
const existingEntry = input.existingEntry || null;
|
|
const holdingMode = normalizeHoldingMode(existingEntry?.holding_mode);
|
|
const automationState = normalizeAutomationState(existingEntry?.automation_state, existingEntry || { status: side === 'buy' ? 'simple_armed_buy' : 'simple_armed_sell' } as ManualEntryPayload);
|
|
|
|
if (side === 'sell' && !holding) {
|
|
throw new Error('Sell setups require an existing holding for this symbol.');
|
|
}
|
|
|
|
if (side === 'buy' && parseNonNegativeNumber(input.draft.dropValue) === null) {
|
|
throw new Error('Drop trigger is required for buy setups');
|
|
}
|
|
|
|
if (side === 'buy') {
|
|
if (input.draft.sizingMode === 'amount') {
|
|
if (!amountUsd) {
|
|
throw new Error('USD amount is required');
|
|
}
|
|
} else if (!quantity) {
|
|
throw new Error('Quantity is required');
|
|
}
|
|
}
|
|
|
|
return {
|
|
stock_instance_id: input.existingId,
|
|
symbol,
|
|
active: true,
|
|
status: side === 'buy' ? 'simple_armed_buy' : 'simple_armed_sell',
|
|
is_crypto: false,
|
|
is_real_trade: false,
|
|
label: 'Simple',
|
|
quantity: side === 'sell' ? holding!.size : (input.draft.sizingMode === 'quantity' ? quantity : null),
|
|
amount_usd: side === 'buy' && input.draft.sizingMode === 'amount' ? amountUsd : null,
|
|
sizing_mode: side === 'buy' ? input.draft.sizingMode : 'quantity',
|
|
filled_quantity: side === 'sell' ? holding!.size : null,
|
|
notes: input.draft.notes.trim() || null,
|
|
entry_price: side === 'sell' ? holding!.entryPrice : null,
|
|
reference_price: currentMarketPrice,
|
|
gain_threshold_for_sell: profitValue,
|
|
drop_threshold_for_buy: side === 'buy' ? parseNonNegativeNumber(input.draft.dropValue) : null,
|
|
workflow_type: 'simple',
|
|
simple_side: side,
|
|
drop_trigger_mode: side === 'buy' ? input.draft.dropMode : null,
|
|
profit_target_mode: input.draft.profitMode,
|
|
linked_trade_id: side === 'sell' ? holding!.tradeId || null : null,
|
|
profile_id: side === 'sell' ? holding!.profileId || null : null,
|
|
holding_mode: existingEntry ? holdingMode : 'short_term',
|
|
automation_state: existingEntry ? automationState : 'armed',
|
|
buy_price: null,
|
|
sell_price: null,
|
|
buy_time: null,
|
|
sell_time: null,
|
|
};
|
|
}
|
|
|
|
function buildPreviewText(draft: SimpleSetupDraft, holding: SimpleHolding | null): string | null {
|
|
const symbol = draft.symbol.trim().toUpperCase();
|
|
if (!symbol) return null;
|
|
|
|
if (draft.side === 'buy') {
|
|
const triggerPrice = computeBuyTriggerPrice(draft);
|
|
const profitTargetPrice = computeProfitTargetPrice(triggerPrice, draft.profitMode, draft.profitValue);
|
|
if (!triggerPrice) return `Buy ${symbol} after the configured drop trigger is hit.`;
|
|
|
|
const dropMagnitude = parseNonNegativeNumber(draft.dropValue);
|
|
const isImmediate = dropMagnitude === 0;
|
|
const dropText = isImmediate
|
|
? 'at the current market reference'
|
|
: draft.dropMode === 'dollar'
|
|
? `$${Number(draft.dropValue || 0).toFixed(2)} below current price`
|
|
: `${draft.dropValue || '0'}% below current price`;
|
|
const profitText = draft.profitMode === 'dollar'
|
|
? `$${Number(draft.profitValue || 0).toFixed(2)} above purchase`
|
|
: `${draft.profitValue || '0'}% above purchase`;
|
|
const sizeText = draft.sizingMode === 'amount'
|
|
? `Spend $${Number(draft.amountUsd || 0).toFixed(2)}`
|
|
: `Buy ${draft.quantity || '0'} units`;
|
|
|
|
return [
|
|
`${sizeText} of ${symbol} when price reaches ${triggerPrice.toFixed(4)} (${dropText}).`,
|
|
profitTargetPrice ? `Exit target stays armed at ${profitTargetPrice.toFixed(4)} (${profitText}).` : `Exit target uses ${profitText}.`,
|
|
].join(' ');
|
|
}
|
|
|
|
if (!holding) {
|
|
return `Sell ${symbol} only works when an eligible existing holding is available.`;
|
|
}
|
|
|
|
const profitTargetPrice = computeProfitTargetPrice(holding.entryPrice, draft.profitMode, draft.profitValue);
|
|
const profitText = draft.profitMode === 'dollar'
|
|
? `$${Number(draft.profitValue || 0).toFixed(2)} above purchase`
|
|
: `${draft.profitValue || '0'}% above purchase`;
|
|
if (!profitTargetPrice) {
|
|
return `Exit ${symbol} when the configured profit target is hit (${profitText}).`;
|
|
}
|
|
|
|
return `Exit ${symbol} when price reaches ${profitTargetPrice.toFixed(4)} (${profitText}).`;
|
|
}
|
|
|
|
function deriveRuntimeSnapshot(
|
|
entry: ManualEntryPayload,
|
|
orders: Array<Record<string, any>>,
|
|
holdings: SimpleHolding[],
|
|
): SimpleRuntimeSnapshot | null {
|
|
const linkedTradeId = String(entry.linked_trade_id || '').trim();
|
|
if (linkedTradeId) {
|
|
const matchingOrder = orders.find((order) => String(order?.trade_id || order?.tradeId || '').trim() === linkedTradeId);
|
|
if (matchingOrder) {
|
|
const status = String(matchingOrder.status || '').trim().toLowerCase();
|
|
const action = String(matchingOrder.action || '').trim().toUpperCase();
|
|
const orderId = String(matchingOrder.orderId || matchingOrder.order_id || matchingOrder.id || '').trim() || undefined;
|
|
if (action === 'EXIT') {
|
|
if (status === 'filled') {
|
|
return { stage: 'closed', label: 'Exit filled', tone: 'success', tradeId: linkedTradeId, orderId };
|
|
}
|
|
return { stage: 'exit_submitted', label: `Exit ${status || 'submitted'}`, tone: 'info', tradeId: linkedTradeId, orderId };
|
|
}
|
|
if (status === 'filled') {
|
|
return { stage: 'filled', label: 'Entry filled', tone: 'success', tradeId: linkedTradeId, orderId };
|
|
}
|
|
return { stage: 'entry_submitted', label: `Entry ${status || 'submitted'}`, tone: 'info', tradeId: linkedTradeId, orderId };
|
|
}
|
|
}
|
|
|
|
const symbol = String(entry.symbol || '').trim().toUpperCase();
|
|
const side = normalizeSetupSide(entry.simple_side);
|
|
const matchingHolding = holdings.find((holding) => holding.symbol === symbol && (!linkedTradeId || holding.tradeId === linkedTradeId || !holding.tradeId));
|
|
if (side === 'buy' && matchingHolding) {
|
|
return {
|
|
stage: 'filled',
|
|
label: 'Portfolio holding open',
|
|
tone: 'success',
|
|
tradeId: matchingHolding.tradeId || linkedTradeId || undefined,
|
|
};
|
|
}
|
|
if (String(entry.status || '').trim().toLowerCase() === 'sellcompleted') {
|
|
return { stage: 'closed', label: 'Position closed', tone: 'success', tradeId: linkedTradeId || undefined };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function buildDraftFromEntry(entry: ManualEntryPayload): SimpleSetupDraft {
|
|
return {
|
|
symbol: String(entry.symbol || '').trim().toUpperCase(),
|
|
side: normalizeSetupSide(entry.simple_side),
|
|
sizingMode: String(entry.sizing_mode || '').trim().toLowerCase() === 'amount' ? 'amount' : 'quantity',
|
|
quantity: entry.quantity ? String(entry.quantity) : '',
|
|
amountUsd: entry.amount_usd ? String(entry.amount_usd) : '',
|
|
currentMarketPrice: entry.reference_price ? Number(entry.reference_price).toFixed(4) : '',
|
|
dropMode: normalizeMode(entry.drop_trigger_mode, 'percent'),
|
|
dropValue: entry.drop_threshold_for_buy !== null && entry.drop_threshold_for_buy !== undefined ? String(entry.drop_threshold_for_buy) : '',
|
|
profitMode: normalizeMode(entry.profit_target_mode, 'percent'),
|
|
profitValue: entry.gain_threshold_for_sell !== null && entry.gain_threshold_for_sell !== undefined ? String(entry.gain_threshold_for_sell) : '',
|
|
notes: String(entry.notes || ''),
|
|
};
|
|
}
|
|
|
|
function inferMarketPriceSourceFromEntry(entry: ManualEntryPayload): MarketPriceSource {
|
|
return entry.reference_price ? 'reference_price' : null;
|
|
}
|
|
|
|
function formatSetupStatus(status?: string | null): string {
|
|
const normalized = String(status || '').trim().toLowerCase();
|
|
switch (normalized) {
|
|
case 'simple_armed_buy':
|
|
return 'Buy armed';
|
|
case 'simple_entry_submitted':
|
|
return 'Buy submitted';
|
|
case 'simple_bought':
|
|
return 'Holding';
|
|
case 'simple_armed_sell':
|
|
return 'Sell armed';
|
|
case 'simple_exit_submitted':
|
|
return 'Exit submitted';
|
|
case 'sellcompleted':
|
|
return 'Closed';
|
|
default:
|
|
return normalized || 'Unknown';
|
|
}
|
|
}
|
|
|
|
function formatHoldingMode(mode?: string | null): string {
|
|
return normalizeHoldingMode(mode) === 'long_term' ? 'Long-term' : 'Short-term';
|
|
}
|
|
|
|
function formatAutomationState(entry: ManualEntryPayload): string {
|
|
const state = normalizeAutomationState(entry.automation_state, entry);
|
|
switch (state) {
|
|
case 'armed':
|
|
return 'Automation armed';
|
|
case 'entry_submitted':
|
|
return 'Entry syncing';
|
|
case 'holding_managed':
|
|
return 'Exit managed';
|
|
case 'paused_long_term':
|
|
return 'Automation paused';
|
|
case 'exit_submitted':
|
|
return 'Exit syncing';
|
|
case 'closed':
|
|
return 'Closed';
|
|
default:
|
|
return 'State syncing';
|
|
}
|
|
}
|
|
|
|
function statusToneClasses(tone: SimpleRuntimeSnapshot['tone']): string {
|
|
switch (tone) {
|
|
case 'success':
|
|
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300';
|
|
case 'info':
|
|
return 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300';
|
|
default:
|
|
return 'border-[var(--border)] text-[var(--muted-foreground)]';
|
|
}
|
|
}
|
|
|
|
const SIMPLE_TIMELINE_STEPS: Array<SimpleRuntimeSnapshot['stage']> = [
|
|
'armed',
|
|
'entry_submitted',
|
|
'filled',
|
|
'exit_submitted',
|
|
'closed',
|
|
];
|
|
|
|
function isTimelineStepComplete(
|
|
current: SimpleRuntimeSnapshot['stage'] | undefined,
|
|
step: SimpleRuntimeSnapshot['stage'],
|
|
): boolean {
|
|
if (!current) return false;
|
|
const currentIndex = SIMPLE_TIMELINE_STEPS.indexOf(current);
|
|
const stepIndex = SIMPLE_TIMELINE_STEPS.indexOf(step);
|
|
return currentIndex >= 0 && stepIndex >= 0 && stepIndex <= currentIndex;
|
|
}
|
|
|
|
function formatTimelineStepLabel(step: SimpleRuntimeSnapshot['stage']) {
|
|
switch (step) {
|
|
case 'armed':
|
|
return 'Armed';
|
|
case 'entry_submitted':
|
|
return 'Entry sent';
|
|
case 'filled':
|
|
return 'Filled';
|
|
case 'exit_submitted':
|
|
return 'Exit sent';
|
|
case 'closed':
|
|
return 'Closed';
|
|
default:
|
|
return step;
|
|
}
|
|
}
|
|
|
|
function describeNextAction(
|
|
entry: ManualEntryPayload,
|
|
runtimeSnapshot: SimpleRuntimeSnapshot | null,
|
|
): string {
|
|
const side = normalizeSetupSide(entry.simple_side);
|
|
const symbol = String(entry.symbol || '').trim().toUpperCase() || 'This symbol';
|
|
|
|
if (!runtimeSnapshot) {
|
|
if (normalizeHoldingMode(entry.holding_mode) === 'long_term') {
|
|
return `${symbol} is being kept as a long-term hold. Automated exit monitoring is paused until you resume it.`;
|
|
}
|
|
return side === 'buy'
|
|
? `${symbol} is saved and waiting for the configured buy trigger.`
|
|
: `${symbol} is saved and waiting for an eligible holding to manage.`;
|
|
}
|
|
|
|
if (normalizeHoldingMode(entry.holding_mode) === 'long_term' && runtimeSnapshot.stage === 'filled') {
|
|
return `${symbol} is held as a long-term position. No automated profit exit is currently armed.`;
|
|
}
|
|
|
|
switch (runtimeSnapshot.stage) {
|
|
case 'armed':
|
|
return side === 'buy'
|
|
? `${symbol} is waiting for the configured drop trigger before sending an entry order.`
|
|
: `${symbol} is waiting for the configured profit exit trigger.`;
|
|
case 'entry_submitted':
|
|
return `${symbol} entry order has been submitted and is waiting for exchange fill confirmation.`;
|
|
case 'filled':
|
|
return `${symbol} entry is filled. The setup is now monitoring for the configured profit exit.`;
|
|
case 'exit_submitted':
|
|
return `${symbol} exit order has been submitted and is waiting for exchange fill confirmation.`;
|
|
case 'closed':
|
|
return `${symbol} setup is complete. The linked position has been closed.`;
|
|
default:
|
|
return `${symbol} runtime state is syncing.`;
|
|
}
|
|
}
|
|
|
|
function formatSetupUpdatedAt(entry: ManualEntryPayload): string | null {
|
|
const raw = String(entry.sell_time || entry.buy_time || '').trim();
|
|
if (!raw) return null;
|
|
const parsed = new Date(raw);
|
|
if (Number.isNaN(parsed.getTime())) return null;
|
|
return parsed.toLocaleString();
|
|
}
|
|
|
|
function formatEventTimestamp(timestamp?: number): string | null {
|
|
if (!timestamp || !Number.isFinite(timestamp)) return null;
|
|
const parsed = new Date(timestamp);
|
|
if (Number.isNaN(parsed.getTime())) return null;
|
|
return parsed.toLocaleString();
|
|
}
|
|
|
|
function deriveSimpleEventHistory(
|
|
entry: ManualEntryPayload,
|
|
operationalEvents: SimpleOperationalEvent[],
|
|
): SimpleOperationalEvent[] {
|
|
const setupId = String(entry.stock_instance_id || '').trim();
|
|
const tradeId = String(entry.linked_trade_id || '').trim();
|
|
const symbol = String(entry.symbol || '').trim().toUpperCase();
|
|
|
|
return operationalEvents
|
|
.filter((event): event is SimpleOperationalEvent => Boolean(event) && event.type === 'SIMPLE_SETUP_UPDATE')
|
|
.filter((event) => {
|
|
const eventSetupId = String(event.setupId || '').trim();
|
|
const eventTradeId = String(event.tradeId || '').trim();
|
|
const eventSymbol = String(event.symbol || '').trim().toUpperCase();
|
|
if (setupId && eventSetupId === setupId) return true;
|
|
if (tradeId && eventTradeId === tradeId) return true;
|
|
return !setupId && !tradeId && !!symbol && eventSymbol === symbol;
|
|
})
|
|
.sort((left, right) => right.timestamp - left.timestamp)
|
|
.slice(0, 5);
|
|
}
|
|
|
|
function eventSeverityClasses(severity?: string) {
|
|
const normalized = String(severity || '').trim().toUpperCase();
|
|
if (normalized === 'ERROR') {
|
|
return 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300';
|
|
}
|
|
if (normalized === 'WARN') {
|
|
return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300';
|
|
}
|
|
return 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300';
|
|
}
|
|
|
|
function normalizeSimpleEntries(entries: ManualEntryPayload[]): ManualEntryPayload[] {
|
|
return entries
|
|
.filter((entry) => String(entry.workflow_type || '').trim().toLowerCase() === 'simple')
|
|
.sort((left, right) => {
|
|
const leftTimestamp = new Date(String(left.sell_time || left.buy_time || '')).getTime() || 0;
|
|
const rightTimestamp = new Date(String(right.sell_time || right.buy_time || '')).getTime() || 0;
|
|
return rightTimestamp - leftTimestamp;
|
|
});
|
|
}
|
|
|
|
function normalizeKnownSymbol(value: unknown): string | null {
|
|
const normalized = String(value || '').trim().toUpperCase();
|
|
return normalized ? normalized : null;
|
|
}
|
|
|
|
function normalizeRuntimeArray<T>(value: unknown): T[] {
|
|
if (Array.isArray(value)) return value as T[];
|
|
if (value && typeof value === 'object' && Symbol.iterator in (value as object)) {
|
|
try {
|
|
return Array.from(value as Iterable<T>);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function isLikelySymbolCandidate(symbol: string): boolean {
|
|
return /^[A-Z0-9./-]{1,20}$/.test(symbol);
|
|
}
|
|
|
|
function extractReferencePriceFromResearchProfile(profile: any): number | null {
|
|
const candidates = [
|
|
profile?.price,
|
|
profile?.lastPrice,
|
|
profile?.currentPrice,
|
|
profile?.previousClose,
|
|
profile?.prevClose,
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
const value = Number(candidate);
|
|
if (Number.isFinite(value) && value > 0) return value;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function describeSavedSetup(entry: ManualEntryPayload): string {
|
|
const side = normalizeSetupSide(entry.simple_side);
|
|
const symbol = String(entry.symbol || '').trim().toUpperCase();
|
|
const dropValue = Number(entry.drop_threshold_for_buy || 0);
|
|
const profitValue = Number(entry.gain_threshold_for_sell || 0);
|
|
const dropMode = normalizeMode(entry.drop_trigger_mode, 'percent');
|
|
const profitMode = normalizeMode(entry.profit_target_mode, 'percent');
|
|
const referencePrice = Number(entry.reference_price || 0);
|
|
const entryPrice = Number(entry.entry_price || 0);
|
|
const sizingMode = String(entry.sizing_mode || 'quantity').trim().toLowerCase();
|
|
const sizeText = sizingMode === 'amount'
|
|
? `$${Number(entry.amount_usd || 0).toFixed(2)} budget`
|
|
: `${Number(entry.quantity || 0).toFixed(6).replace(/\.?0+$/, '')} units`;
|
|
|
|
if (side === 'buy') {
|
|
const triggerPrice = computeBuyTriggerPrice(buildDraftFromEntry(entry));
|
|
const dropText = dropValue === 0
|
|
? 'at current reference'
|
|
: dropMode === 'dollar'
|
|
? `$${dropValue.toFixed(2)} below`
|
|
: `${dropValue}% below`;
|
|
const profitText = profitMode === 'dollar'
|
|
? `$${profitValue.toFixed(2)} above purchase`
|
|
: `${profitValue}% above purchase`;
|
|
return `Buy ${symbol} using ${sizeText} ${dropText} ${referencePrice > 0 ? `(${referencePrice.toFixed(4)} ref` : ''}${triggerPrice ? ` → ${triggerPrice.toFixed(4)}` : ''}). Exit at ${profitText}.`;
|
|
}
|
|
|
|
const profitTargetPrice = computeProfitTargetPrice(entryPrice || null, profitMode, String(profitValue || ''));
|
|
const profitText = profitMode === 'dollar'
|
|
? `$${profitValue.toFixed(2)} above purchase`
|
|
: `${profitValue}% above purchase`;
|
|
return `Exit full ${symbol} holding at ${profitText}${profitTargetPrice ? ` (${profitTargetPrice.toFixed(4)})` : ''}.`;
|
|
}
|
|
|
|
export function SimpleView() {
|
|
const { botState } = useAppContext();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const [profiles, setProfiles] = useState<TradeProfilePayload[]>([]);
|
|
const [savedSetups, setSavedSetups] = useState<ManualEntryPayload[]>([]);
|
|
const [editingSetupId, setEditingSetupId] = useState<string | null>(null);
|
|
const [draft, setDraft] = useState<SimpleSetupDraft>(DEFAULT_DRAFT);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [loadingPrice, setLoadingPrice] = useState(false);
|
|
const [marketPriceSource, setMarketPriceSource] = useState<MarketPriceSource>(null);
|
|
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
|
const [message, setMessage] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [selectedHoldingTradeId, setSelectedHoldingTradeId] = useState<string | null>(null);
|
|
const [focusedSetupId, setFocusedSetupId] = useState<string | null>(null);
|
|
const marketPriceRequestSymbolRef = useRef<string>('');
|
|
const consumedPrefillRef = useRef(false);
|
|
const consumedSetupFocusRef = useRef(false);
|
|
const setupCardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
|
|
|
const normalizedSymbol = draft.symbol.trim().toUpperCase();
|
|
const symbolState = botState?.symbols && typeof botState.symbols === 'object' ? botState.symbols : {};
|
|
const livePrice = normalizedSymbol ? Number(symbolState?.[normalizedSymbol]?.price || 0) : 0;
|
|
const simpleAutoProfile = useMemo(
|
|
() => profiles.find((profile) => matchesSimpleAutoProfile(profile)) || null,
|
|
[profiles],
|
|
);
|
|
|
|
const simpleHoldings = useMemo(() => {
|
|
const positions = normalizeRuntimeArray<typeof botState.positions[number]>(botState?.positions);
|
|
const simpleProfileId = simpleAutoProfile?.id;
|
|
return positions
|
|
.filter((position) => !simpleProfileId || position.profileId === simpleProfileId)
|
|
.map((position) => ({
|
|
symbol: String(position.symbol || '').trim().toUpperCase(),
|
|
size: Number(position.size || 0),
|
|
entryPrice: Number(position.entryPrice || 0),
|
|
profileId: position.profileId,
|
|
tradeId: position.tradeId,
|
|
}))
|
|
.filter((position) => position.symbol && position.size > 0 && position.entryPrice > 0);
|
|
}, [botState?.positions, simpleAutoProfile?.id]);
|
|
|
|
const availableSellHoldings = useMemo(() => {
|
|
const positions = normalizeRuntimeArray<typeof botState.positions[number]>(botState?.positions);
|
|
return positions
|
|
.map((position) => ({
|
|
symbol: String(position.symbol || '').trim().toUpperCase(),
|
|
size: Number(position.size || 0),
|
|
entryPrice: Number(position.entryPrice || 0),
|
|
profileId: position.profileId,
|
|
tradeId: position.tradeId,
|
|
}))
|
|
.filter((position) => position.symbol && position.size > 0 && position.entryPrice > 0 && position.profileId && position.tradeId)
|
|
.sort((left, right) => {
|
|
const bySymbol = left.symbol.localeCompare(right.symbol);
|
|
if (bySymbol !== 0) return bySymbol;
|
|
return String(left.tradeId || '').localeCompare(String(right.tradeId || ''));
|
|
});
|
|
}, [botState?.positions]);
|
|
|
|
const runtimeOrders = useMemo(() => {
|
|
const orders = normalizeRuntimeArray<Record<string, any>>(botState?.orders);
|
|
return orders.filter((order) => {
|
|
if (!simpleAutoProfile?.id) return true;
|
|
return String(order.profileId || '').trim() === simpleAutoProfile.id;
|
|
});
|
|
}, [botState?.orders, simpleAutoProfile?.id]);
|
|
const runtimeEvents = useMemo(
|
|
() => normalizeRuntimeArray<SimpleOperationalEvent>(botState?.operationalEvents),
|
|
[botState?.operationalEvents],
|
|
);
|
|
|
|
const selectedSellHolding = useMemo(() => {
|
|
if (selectedHoldingTradeId) {
|
|
return availableSellHoldings.find((holding) => holding.tradeId === selectedHoldingTradeId) || null;
|
|
}
|
|
return availableSellHoldings.find((holding) => holding.symbol === normalizedSymbol) || null;
|
|
}, [availableSellHoldings, normalizedSymbol, selectedHoldingTradeId]);
|
|
|
|
const supportedSymbols = useMemo(() => {
|
|
const fromState = Object.keys(symbolState || {}).map(normalizeKnownSymbol).filter(Boolean) as string[];
|
|
const fromSetups = savedSetups.map((entry) => normalizeKnownSymbol(entry.symbol)).filter(Boolean) as string[];
|
|
const fromHoldings = availableSellHoldings.map((holding) => normalizeKnownSymbol(holding.symbol)).filter(Boolean) as string[];
|
|
return Array.from(new Set([...fromState, ...fromSetups, ...fromHoldings, ...COMMON_SIMPLE_SYMBOLS])).sort((left, right) => left.localeCompare(right));
|
|
}, [symbolState, savedSetups, availableSellHoldings]);
|
|
|
|
const filteredSymbolSuggestions = useMemo(() => {
|
|
if (!normalizedSymbol) {
|
|
return supportedSymbols.slice(0, 8);
|
|
}
|
|
const startsWith = supportedSymbols.filter((symbol) => symbol.startsWith(normalizedSymbol));
|
|
const includes = supportedSymbols.filter((symbol) => !symbol.startsWith(normalizedSymbol) && symbol.includes(normalizedSymbol));
|
|
return [...startsWith, ...includes].slice(0, 8);
|
|
}, [normalizedSymbol, supportedSymbols]);
|
|
|
|
const isSuggestedSymbol = normalizedSymbol ? supportedSymbols.includes(normalizedSymbol) : false;
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
async function loadData() {
|
|
try {
|
|
const [profileRows, entryRows] = await Promise.all([
|
|
fetchTradeProfiles(),
|
|
fetchManualEntries(),
|
|
]);
|
|
if (cancelled) return;
|
|
setProfiles(profileRows);
|
|
setSavedSetups(normalizeSimpleEntries(entryRows));
|
|
} catch (err: any) {
|
|
if (cancelled) return;
|
|
setError(err?.message ?? 'Failed to load Simple setups');
|
|
}
|
|
}
|
|
|
|
void loadData();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!livePrice) return;
|
|
setMarketPriceSource('live');
|
|
setDraft((prev) => (
|
|
prev.currentMarketPrice === livePrice.toFixed(4)
|
|
? prev
|
|
: { ...prev, currentMarketPrice: livePrice.toFixed(4) }
|
|
));
|
|
}, [livePrice]);
|
|
|
|
useEffect(() => {
|
|
if (!normalizedSymbol || livePrice > 0 || draft.currentMarketPrice.trim() || loadingPrice) {
|
|
return;
|
|
}
|
|
|
|
if (!isLikelySymbolCandidate(normalizedSymbol)) {
|
|
return;
|
|
}
|
|
|
|
if (!supportedSymbols.includes(normalizedSymbol)) {
|
|
return;
|
|
}
|
|
|
|
const timer = window.setTimeout(() => {
|
|
void handleLoadMarketPrice({ silent: true });
|
|
}, 250);
|
|
|
|
return () => {
|
|
window.clearTimeout(timer);
|
|
};
|
|
}, [normalizedSymbol, livePrice, draft.currentMarketPrice, loadingPrice, supportedSymbols]);
|
|
|
|
const previewText = useMemo(
|
|
() => buildPreviewText(draft, draft.side === 'sell' ? selectedSellHolding : null),
|
|
[draft, selectedSellHolding],
|
|
);
|
|
|
|
function updateDraft<K extends keyof SimpleSetupDraft>(key: K, value: SimpleSetupDraft[K]) {
|
|
if (key === 'side' && value === 'buy') {
|
|
setSelectedHoldingTradeId(null);
|
|
}
|
|
if (key === 'symbol' && draft.side === 'sell') {
|
|
setSelectedHoldingTradeId(null);
|
|
}
|
|
setDraft((prev) => ({ ...prev, [key]: value }));
|
|
}
|
|
|
|
function applyHoldingToDraft(holding: SimpleHolding) {
|
|
setMarketPriceSource(null);
|
|
setSelectedHoldingTradeId(holding.tradeId || null);
|
|
setDraft((prev) => ({
|
|
...prev,
|
|
side: 'sell',
|
|
symbol: holding.symbol,
|
|
quantity: String(holding.size),
|
|
currentMarketPrice: '',
|
|
}));
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (draft.side !== 'sell') return;
|
|
if (selectedSellHolding) return;
|
|
if (availableSellHoldings.length === 0) return;
|
|
applyHoldingToDraft(availableSellHoldings[0]);
|
|
}, [draft.side, selectedSellHolding, availableSellHoldings]);
|
|
|
|
useEffect(() => {
|
|
if (consumedPrefillRef.current) return;
|
|
const requestedMode = String(searchParams.get('mode') || '').trim().toLowerCase();
|
|
const requestedSymbol = String(searchParams.get('symbol') || '').trim().toUpperCase();
|
|
const requestedTradeId = String(searchParams.get('tradeId') || '').trim();
|
|
if (requestedMode !== 'sell') return;
|
|
if (availableSellHoldings.length === 0) return;
|
|
|
|
const selected = (requestedTradeId
|
|
? availableSellHoldings.find((holding) => holding.tradeId === requestedTradeId)
|
|
: null)
|
|
|| (requestedSymbol
|
|
? availableSellHoldings.find((holding) => holding.symbol === requestedSymbol)
|
|
: null)
|
|
|| availableSellHoldings[0];
|
|
|
|
if (selected) {
|
|
applyHoldingToDraft(selected);
|
|
setMessage(`Loaded ${selected.symbol} from Portfolio. Configure the profit target and save the plan.`);
|
|
setError(null);
|
|
}
|
|
consumedPrefillRef.current = true;
|
|
setSearchParams({}, { replace: true });
|
|
}, [availableSellHoldings, searchParams, setSearchParams]);
|
|
|
|
useEffect(() => {
|
|
if (consumedSetupFocusRef.current) return;
|
|
const requestedSetupId = String(searchParams.get('setupId') || '').trim();
|
|
if (!requestedSetupId) return;
|
|
if (savedSetups.length === 0) return;
|
|
|
|
const targetEntry = savedSetups.find((entry) => String(entry.stock_instance_id || '') === requestedSetupId) || null;
|
|
consumedSetupFocusRef.current = true;
|
|
setSearchParams({}, { replace: true });
|
|
|
|
if (!targetEntry) return;
|
|
|
|
setFocusedSetupId(requestedSetupId);
|
|
setMessage(`Focused saved plan for ${targetEntry.symbol}.`);
|
|
setError(null);
|
|
window.setTimeout(() => setFocusedSetupId((prev) => (prev === requestedSetupId ? null : prev)), 2200);
|
|
window.requestAnimationFrame(() => {
|
|
setupCardRefs.current[requestedSetupId]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
});
|
|
}, [savedSetups, searchParams, setSearchParams]);
|
|
|
|
async function copyIdentifier(kind: 'trade' | 'order', value: string | null | undefined) {
|
|
if (!value) return;
|
|
try {
|
|
await navigator.clipboard.writeText(value);
|
|
const key = `${kind}:${value}`;
|
|
setCopiedKey(key);
|
|
window.setTimeout(() => {
|
|
setCopiedKey((prev) => (prev === key ? null : prev));
|
|
}, 1200);
|
|
} catch {
|
|
setError(`Failed to copy ${kind} ID`);
|
|
}
|
|
}
|
|
|
|
async function refreshSetupList() {
|
|
const [profileRows, entryRows] = await Promise.all([
|
|
fetchTradeProfiles(),
|
|
fetchManualEntries(),
|
|
]);
|
|
setProfiles(profileRows);
|
|
setSavedSetups(normalizeSimpleEntries(entryRows));
|
|
}
|
|
|
|
async function updateSavedSetup(entryId: string, updater: (entry: ManualEntryPayload) => ManualEntryPayload) {
|
|
const existing = savedSetups.find((entry) => String(entry.stock_instance_id || '') === entryId);
|
|
if (!existing) return;
|
|
const updated = await updateManualEntry(entryId, updater(existing));
|
|
setSavedSetups((prev) => normalizeSimpleEntries(prev.map((entry) => (
|
|
String(entry.stock_instance_id || '') === entryId ? updated : entry
|
|
))));
|
|
}
|
|
|
|
function setMarketPriceValue(value: string, source: MarketPriceSource) {
|
|
setMarketPriceSource(source);
|
|
updateDraft('currentMarketPrice', value);
|
|
}
|
|
|
|
async function handleLoadMarketPrice(options?: { silent?: boolean }) {
|
|
if (!normalizedSymbol) return;
|
|
const requestSymbol = normalizedSymbol;
|
|
marketPriceRequestSymbolRef.current = requestSymbol;
|
|
setLoadingPrice(true);
|
|
if (!options?.silent) {
|
|
setError(null);
|
|
setMessage(null);
|
|
}
|
|
|
|
try {
|
|
if (livePrice > 0) {
|
|
if (marketPriceRequestSymbolRef.current === requestSymbol) {
|
|
setMarketPriceValue(livePrice.toFixed(4), 'live');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const bars = await fetchChartBars(requestSymbol, '1D');
|
|
const lastClose = Number(bars?.[bars.length - 1]?.close || 0);
|
|
if (Number.isFinite(lastClose) && lastClose > 0) {
|
|
if (marketPriceRequestSymbolRef.current === requestSymbol) {
|
|
setMarketPriceValue(lastClose.toFixed(4), 'latest_close');
|
|
}
|
|
} else {
|
|
const researchProfile = await fetchResearchProfile(requestSymbol).catch(() => null);
|
|
const fallbackReferencePrice = extractReferencePriceFromResearchProfile(researchProfile);
|
|
if (fallbackReferencePrice && marketPriceRequestSymbolRef.current === requestSymbol) {
|
|
setMarketPriceValue(fallbackReferencePrice.toFixed(4), 'reference_price');
|
|
return;
|
|
}
|
|
throw new Error('No live price or latest close is available for this symbol right now.');
|
|
}
|
|
} catch (err: any) {
|
|
if (!options?.silent && marketPriceRequestSymbolRef.current === requestSymbol) {
|
|
setError(err?.message ?? 'Failed to load market data');
|
|
}
|
|
} finally {
|
|
if (marketPriceRequestSymbolRef.current === requestSymbol) {
|
|
setLoadingPrice(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handleSubmit(e: FormEvent) {
|
|
e.preventDefault();
|
|
setSubmitting(true);
|
|
setError(null);
|
|
setMessage(null);
|
|
|
|
try {
|
|
const existingEntry = editingSetupId
|
|
? (savedSetups.find((entry) => String(entry.stock_instance_id || '') === editingSetupId) || null)
|
|
: null;
|
|
const payload = buildSimpleSetupPayload({
|
|
draft,
|
|
existingId: editingSetupId || undefined,
|
|
holding: draft.side === 'sell' ? selectedSellHolding : null,
|
|
existingEntry,
|
|
});
|
|
|
|
if (editingSetupId) {
|
|
await updateManualEntry(editingSetupId, payload);
|
|
setMessage(`Updated ${normalizedSymbol} Simple setup.`);
|
|
} else {
|
|
await createManualEntry({
|
|
...payload,
|
|
stock_instance_id: crypto.randomUUID(),
|
|
});
|
|
setMessage(`Saved ${normalizedSymbol} Simple setup.`);
|
|
}
|
|
|
|
await refreshSetupList();
|
|
setEditingSetupId(null);
|
|
setMarketPriceSource(draft.currentMarketPrice ? marketPriceSource : null);
|
|
setDraft({
|
|
...DEFAULT_DRAFT,
|
|
currentMarketPrice: draft.currentMarketPrice,
|
|
});
|
|
} catch (err: any) {
|
|
setError(err?.message ?? 'Failed to save Simple setup');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
function handleEdit(entry: ManualEntryPayload) {
|
|
setEditingSetupId(String(entry.stock_instance_id || ''));
|
|
setMarketPriceSource(inferMarketPriceSourceFromEntry(entry));
|
|
setDraft(buildDraftFromEntry(entry));
|
|
setMessage(null);
|
|
setError(null);
|
|
}
|
|
|
|
async function handleDelete(entryId: string) {
|
|
if (!window.confirm('Delete this Simple setup?')) return;
|
|
try {
|
|
await deleteManualEntry(entryId);
|
|
if (editingSetupId === entryId) {
|
|
setEditingSetupId(null);
|
|
setMarketPriceSource(null);
|
|
setDraft(DEFAULT_DRAFT);
|
|
}
|
|
await refreshSetupList();
|
|
} catch (err: any) {
|
|
setError(err?.message ?? 'Failed to delete Simple setup');
|
|
}
|
|
}
|
|
|
|
async function handleConvertToLongTerm(entry: ManualEntryPayload) {
|
|
const entryId = String(entry.stock_instance_id || '');
|
|
if (!entryId) return;
|
|
setError(null);
|
|
setMessage(null);
|
|
try {
|
|
await updateSavedSetup(entryId, (current) => ({
|
|
...current,
|
|
holding_mode: 'long_term',
|
|
automation_state: 'paused_long_term',
|
|
status: 'simple_bought',
|
|
active: true,
|
|
}));
|
|
setMessage(`${String(entry.symbol || '').trim().toUpperCase()} is now treated as a long-term hold. Automated exit monitoring is paused.`);
|
|
} catch (err: any) {
|
|
setError(err?.message ?? 'Failed to convert setup to long-term mode');
|
|
}
|
|
}
|
|
|
|
async function handleResumeExitManagement(entry: ManualEntryPayload) {
|
|
const entryId = String(entry.stock_instance_id || '');
|
|
if (!entryId) return;
|
|
setError(null);
|
|
setMessage(null);
|
|
try {
|
|
await updateSavedSetup(entryId, (current) => ({
|
|
...current,
|
|
holding_mode: 'short_term',
|
|
automation_state: 'holding_managed',
|
|
status: 'simple_bought',
|
|
active: true,
|
|
}));
|
|
setMessage(`${String(entry.symbol || '').trim().toUpperCase()} is back under short-term exit management.`);
|
|
} catch (err: any) {
|
|
setError(err?.message ?? 'Failed to resume short-term exit management');
|
|
}
|
|
}
|
|
|
|
const saveButtonLabel = editingSetupId ? 'Update setup' : 'Save setup';
|
|
const saveButtonDisabled = submitting || loadingPrice || (draft.side === 'sell' && !selectedSellHolding);
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<PageHeader
|
|
title="Trade Plans"
|
|
description="Create and manage short-term trade plans, then convert filled positions into long-term holds when you want to stop automated exits."
|
|
/>
|
|
|
|
<div className="grid gap-8 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
|
<Card>
|
|
<CardHeader>
|
|
<div>
|
|
<CardTitle className="uppercase">{editingSetupId ? 'Edit setup' : 'New setup'}</CardTitle>
|
|
<CardDescription>Build a short-term buy plan or attach a managed profit exit to an existing holding.</CardDescription>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
onClick={() => {
|
|
setEditingSetupId(null);
|
|
setSelectedHoldingTradeId(null);
|
|
setMarketPriceSource(draft.currentMarketPrice ? marketPriceSource : null);
|
|
setDraft({
|
|
...DEFAULT_DRAFT,
|
|
currentMarketPrice: draft.currentMarketPrice,
|
|
});
|
|
setMessage(null);
|
|
setError(null);
|
|
}}
|
|
variant="outline"
|
|
size="sm"
|
|
className="uppercase tracking-[0.2em]"
|
|
>
|
|
Reset
|
|
</Button>
|
|
</CardHeader>
|
|
|
|
<CardContent>
|
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setError(null);
|
|
setMessage(null);
|
|
setSelectedHoldingTradeId(null);
|
|
setDraft((prev) => ({ ...prev, side: 'buy' }));
|
|
}}
|
|
className={`rounded-[1.25rem] border px-4 py-4 text-left transition ${
|
|
draft.side === 'buy'
|
|
? 'border-[var(--primary)] bg-[var(--accent-soft)]'
|
|
: 'border-[var(--border)] bg-[var(--card-elevated)]'
|
|
}`}
|
|
>
|
|
<div className="text-[11px] font-black uppercase tracking-[0.24em] text-[var(--muted-foreground)]">Create plan</div>
|
|
<div className="mt-1 text-sm font-semibold text-[var(--foreground)]">New short-term buy plan</div>
|
|
<div className="mt-1 text-sm text-[var(--muted-foreground)]">Arm a dip-buy trigger and let the app manage the profit exit after fill.</div>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setError(null);
|
|
setMessage(null);
|
|
if (availableSellHoldings.length > 0) {
|
|
applyHoldingToDraft(availableSellHoldings[0]);
|
|
} else {
|
|
setDraft((prev) => ({ ...prev, side: 'sell' }));
|
|
}
|
|
}}
|
|
className={`rounded-[1.25rem] border px-4 py-4 text-left transition ${
|
|
draft.side === 'sell'
|
|
? 'border-[var(--primary)] bg-[var(--accent-soft)]'
|
|
: 'border-[var(--border)] bg-[var(--card-elevated)]'
|
|
}`}
|
|
>
|
|
<div className="text-[11px] font-black uppercase tracking-[0.24em] text-[var(--muted-foreground)]">Manage holding</div>
|
|
<div className="mt-1 text-sm font-semibold text-[var(--foreground)]">Attach an exit plan</div>
|
|
<div className="mt-1 text-sm text-[var(--muted-foreground)]">Choose an existing holding and place it back under managed profit-taking.</div>
|
|
</button>
|
|
</div>
|
|
|
|
{draft.side === 'sell' && (
|
|
<label className="space-y-2">
|
|
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">Existing holding</span>
|
|
<Select
|
|
value={selectedHoldingTradeId || ''}
|
|
onChange={(e) => {
|
|
const selected = availableSellHoldings.find((holding) => (holding.tradeId || '') === e.target.value);
|
|
if (selected) applyHoldingToDraft(selected);
|
|
}}
|
|
disabled={availableSellHoldings.length === 0}
|
|
>
|
|
{availableSellHoldings.length === 0 ? (
|
|
<option value="">No eligible holdings available</option>
|
|
) : (
|
|
availableSellHoldings.map((holding) => (
|
|
<option key={`${holding.symbol}:${holding.tradeId || 'holding'}`} value={holding.tradeId || ''}>
|
|
{holding.symbol} · {holding.size} @ {holding.entryPrice.toFixed(4)}
|
|
</option>
|
|
))
|
|
)}
|
|
</Select>
|
|
<span className="block text-[11px] text-[var(--muted-foreground)]">
|
|
Trade Plans can manage an existing filled holding by attaching a profit exit target to it.
|
|
</span>
|
|
</label>
|
|
)}
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<label className="space-y-2">
|
|
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">Symbol</span>
|
|
<Input
|
|
value={draft.symbol}
|
|
onChange={(e) => {
|
|
setMarketPriceSource(null);
|
|
setDraft((prev) => ({
|
|
...prev,
|
|
symbol: e.target.value.toUpperCase(),
|
|
currentMarketPrice: '',
|
|
}));
|
|
}}
|
|
list={SIMPLE_SYMBOL_DATALIST_ID}
|
|
placeholder="AAPL"
|
|
/>
|
|
<datalist id={SIMPLE_SYMBOL_DATALIST_ID}>
|
|
{supportedSymbols.map((symbol) => (
|
|
<option key={symbol} value={symbol} />
|
|
))}
|
|
</datalist>
|
|
<span className="block text-[11px] text-[var(--muted-foreground)]">
|
|
Start typing to pick a supported symbol. Suggestions come from live market symbols plus common supported assets.
|
|
</span>
|
|
{normalizedSymbol && !isSuggestedSymbol ? (
|
|
<span className="block text-[11px] text-amber-700 dark:text-amber-300">
|
|
This symbol is not in the current supported suggestions. Double-check it before saving.
|
|
</span>
|
|
) : null}
|
|
{filteredSymbolSuggestions.length > 0 ? (
|
|
<div className="flex flex-wrap gap-2 pt-1">
|
|
{filteredSymbolSuggestions.map((symbol) => (
|
|
<button
|
|
key={symbol}
|
|
type="button"
|
|
onClick={() => {
|
|
setMarketPriceSource(null);
|
|
setDraft((prev) => ({
|
|
...prev,
|
|
symbol,
|
|
currentMarketPrice: '',
|
|
}));
|
|
}}
|
|
className={`rounded-full border px-3 py-1 text-[11px] font-semibold transition ${
|
|
symbol === normalizedSymbol
|
|
? 'border-[var(--primary)] bg-[var(--accent-soft)] text-[var(--primary)]'
|
|
: 'border-[var(--border)] bg-[var(--card-elevated)] text-[var(--muted-foreground)] hover:border-[var(--primary)] hover:text-[var(--foreground)]'
|
|
}`}
|
|
>
|
|
{symbol}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</label>
|
|
|
|
<label className="space-y-2">
|
|
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">Setup type</span>
|
|
<Select
|
|
value={draft.side}
|
|
onChange={(e) => updateDraft('side', e.target.value as SimpleSide)}
|
|
>
|
|
<option value="buy">Buy the dip + profit exit</option>
|
|
<option value="sell">Manage existing holding at profit</option>
|
|
</Select>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]">
|
|
<label className="space-y-2">
|
|
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">Market price (auto-fetched)</span>
|
|
<Input
|
|
value={draft.currentMarketPrice}
|
|
readOnly
|
|
className="bg-[var(--muted)] text-[var(--foreground)]"
|
|
/>
|
|
<div className="space-y-1">
|
|
<span className="block text-[11px] text-[var(--muted-foreground)]">
|
|
Uses live market price when available. Outside market hours, it falls back to the latest close.
|
|
</span>
|
|
<span className="block text-[11px] text-[var(--muted-foreground)]">
|
|
Price source:{' '}
|
|
<span className="font-semibold text-[var(--foreground)]">
|
|
{marketPriceSource === 'live'
|
|
? 'Live'
|
|
: marketPriceSource === 'latest_close'
|
|
? 'Latest close'
|
|
: marketPriceSource === 'reference_price'
|
|
? 'Reference price'
|
|
: 'Waiting for symbol data'}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</label>
|
|
<Button
|
|
type="button"
|
|
onClick={() => void handleLoadMarketPrice()}
|
|
className="self-end uppercase tracking-[0.2em]"
|
|
variant="outline"
|
|
disabled={loadingPrice || !normalizedSymbol}
|
|
>
|
|
<span className="inline-flex items-center gap-2">
|
|
<RefreshCw size={14} className={loadingPrice ? 'animate-spin' : ''} />
|
|
Refresh
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{draft.side === 'buy' ? (
|
|
<>
|
|
<label className="space-y-2">
|
|
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">Sizing method</span>
|
|
<Select
|
|
value={draft.sizingMode}
|
|
onChange={(e) => updateDraft('sizingMode', e.target.value as 'quantity' | 'amount')}
|
|
>
|
|
<option value="quantity">Quantity / fractional shares</option>
|
|
<option value="amount">USD amount</option>
|
|
</Select>
|
|
</label>
|
|
|
|
<label className="space-y-2">
|
|
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-500">
|
|
{draft.sizingMode === 'amount' ? 'Spend amount (USD)' : 'Planned quantity'}
|
|
</span>
|
|
<Input
|
|
value={draft.sizingMode === 'amount' ? draft.amountUsd : draft.quantity}
|
|
onChange={(e) => {
|
|
if (draft.sizingMode === 'amount') {
|
|
updateDraft('amountUsd', e.target.value);
|
|
} else {
|
|
updateDraft('quantity', e.target.value);
|
|
}
|
|
}}
|
|
placeholder={draft.sizingMode === 'amount' ? '250' : '10'}
|
|
/>
|
|
<span className="block text-[11px] text-[var(--muted-foreground)]">
|
|
Quantity supports fractional shares/coins. Amount spends an approximate USD budget at trigger time.
|
|
</span>
|
|
<span className="block text-[11px] text-[var(--muted-foreground)]">
|
|
Use quantity when you know the units you want. Use amount to budget dollars and let the app derive fractional size at entry.
|
|
</span>
|
|
</label>
|
|
</>
|
|
) : (
|
|
<label className="space-y-2">
|
|
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-500">
|
|
Holding size
|
|
</span>
|
|
<Input
|
|
value={draft.side === 'sell' && selectedSellHolding ? String(selectedSellHolding.size) : draft.quantity}
|
|
onChange={(e) => updateDraft('quantity', e.target.value)}
|
|
readOnly={draft.side === 'sell' && !!selectedSellHolding}
|
|
className="read-only:bg-[var(--muted)]"
|
|
placeholder="10"
|
|
/>
|
|
</label>
|
|
)}
|
|
|
|
<label className="space-y-2">
|
|
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">Notes</span>
|
|
<Input
|
|
value={draft.notes}
|
|
onChange={(e) => updateDraft('notes', e.target.value)}
|
|
placeholder="Optional context"
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
{draft.side === 'buy' && (
|
|
<div className="grid gap-4 rounded-[1.5rem] border border-[var(--border)] bg-[var(--card-elevated)] p-5 md:grid-cols-[0.55fr_0.45fr]">
|
|
<div className="space-y-3">
|
|
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">Drop trigger</p>
|
|
<Select
|
|
value={draft.dropMode}
|
|
onChange={(e) => updateDraft('dropMode', e.target.value as TriggerMode)}
|
|
>
|
|
<option value="dollar">Dollar drop from current market</option>
|
|
<option value="percent">Percent drop from current market</option>
|
|
</Select>
|
|
</div>
|
|
<label className="space-y-3">
|
|
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">
|
|
{draft.dropMode === 'dollar' ? 'Drop amount ($)' : 'Drop amount (%)'}
|
|
</span>
|
|
<Input
|
|
value={draft.dropValue}
|
|
onChange={(e) => updateDraft('dropValue', e.target.value)}
|
|
placeholder={draft.dropMode === 'dollar' ? '0.00' : '0'}
|
|
/>
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid gap-4 rounded-[1.5rem] border border-[var(--border)] bg-[var(--card-elevated)] p-5 md:grid-cols-[0.55fr_0.45fr]">
|
|
<div className="space-y-3">
|
|
<p className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">Profit exit</p>
|
|
<Select
|
|
value={draft.profitMode}
|
|
onChange={(e) => updateDraft('profitMode', e.target.value as TriggerMode)}
|
|
>
|
|
<option value="dollar">Dollar gain from purchase</option>
|
|
<option value="percent">Percent gain from purchase</option>
|
|
</Select>
|
|
</div>
|
|
<label className="space-y-3">
|
|
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-700">
|
|
{draft.profitMode === 'dollar' ? 'Profit target ($)' : 'Profit target (%)'}
|
|
</span>
|
|
<Input
|
|
value={draft.profitValue}
|
|
onChange={(e) => updateDraft('profitValue', e.target.value)}
|
|
placeholder={draft.profitMode === 'dollar' ? '7.50' : '10'}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
{draft.side === 'sell' && (
|
|
<div className={`rounded-[1.5rem] border px-4 py-4 text-sm ${
|
|
selectedSellHolding
|
|
? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
|
|
: 'border-red-500/20 bg-red-500/10 text-red-700 dark:text-red-300'
|
|
}`}>
|
|
{selectedSellHolding
|
|
? `Holding ready: ${selectedSellHolding.symbol} · ${selectedSellHolding.size} units at ${selectedSellHolding.entryPrice.toFixed(4)}. Executed buys also appear in Portfolio as live positions.`
|
|
: 'No eligible live holding found for this symbol yet. Managed sell setups only arm against a current holding.'}
|
|
</div>
|
|
)}
|
|
|
|
{previewText && (
|
|
<div className="rounded-[1.5rem] border border-[var(--border)] bg-[var(--accent-soft)] px-5 py-4 text-sm text-[var(--foreground)]">
|
|
{previewText}
|
|
</div>
|
|
)}
|
|
|
|
{message && (
|
|
<div className="rounded-2xl border border-emerald-500/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-700 dark:text-emerald-300">
|
|
{message}
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="rounded-2xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-300">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<Button
|
|
type="submit"
|
|
disabled={saveButtonDisabled}
|
|
className="w-full uppercase tracking-[0.24em]"
|
|
size="lg"
|
|
>
|
|
{submitting ? 'Saving...' : saveButtonLabel}
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="block">
|
|
<CardTitle className="uppercase">Saved setups</CardTitle>
|
|
<CardDescription>Review armed setups, current runtime order state, and whether an executed setup is now showing in Portfolio as an open holding.</CardDescription>
|
|
</CardHeader>
|
|
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
{savedSetups.length === 0 && (
|
|
<div className="rounded-[1.5rem] border border-dashed border-[var(--border)] bg-[var(--card-elevated)] px-5 py-8 text-sm text-[var(--muted-foreground)]">
|
|
No Simple setups saved yet.
|
|
</div>
|
|
)}
|
|
|
|
{savedSetups.map((entry) => {
|
|
const entryId = String(entry.stock_instance_id || '');
|
|
const side = normalizeSetupSide(entry.simple_side);
|
|
const isEditing = editingSetupId === entryId;
|
|
const runtimeSnapshot = deriveRuntimeSnapshot(entry, runtimeOrders, simpleHoldings);
|
|
const nextActionText = describeNextAction(entry, runtimeSnapshot);
|
|
const updatedAt = formatSetupUpdatedAt(entry);
|
|
const eventHistory = deriveSimpleEventHistory(entry, runtimeEvents);
|
|
const holdingMode = normalizeHoldingMode(entry.holding_mode);
|
|
const canConvertToLongTerm = side === 'buy' && holdingMode === 'short_term' && runtimeSnapshot?.stage === 'filled';
|
|
const canResumeExitManagement = side === 'buy' && holdingMode === 'long_term' && runtimeSnapshot?.stage === 'filled';
|
|
return (
|
|
<div
|
|
key={entryId}
|
|
ref={(node) => {
|
|
setupCardRefs.current[entryId] = node;
|
|
}}
|
|
className={`rounded-[1.5rem] border bg-[var(--card-elevated)] p-5 transition ${
|
|
focusedSetupId === entryId
|
|
? 'border-[var(--primary)] ring-2 ring-[var(--primary)]/20'
|
|
: 'border-[var(--border)]'
|
|
}`}
|
|
>
|
|
<div className="mb-3 flex items-start justify-between gap-4">
|
|
<div>
|
|
<div className="flex items-center gap-3">
|
|
<h3 className="text-lg font-black uppercase text-[var(--foreground)]">{entry.symbol}</h3>
|
|
<span className={`rounded-full px-3 py-1 text-[10px] font-black uppercase tracking-[0.2em] ${
|
|
side === 'buy' ? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300' : 'bg-violet-500/10 text-violet-700 dark:text-violet-300'
|
|
}`}>
|
|
{side}
|
|
</span>
|
|
</div>
|
|
<p className="mt-2 text-sm text-[var(--muted-foreground)]">{describeSavedSetup(entry)}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{canConvertToLongTerm ? (
|
|
<Button
|
|
type="button"
|
|
onClick={() => void handleConvertToLongTerm(entry)}
|
|
variant="outline"
|
|
size="sm"
|
|
className="uppercase tracking-[0.18em]"
|
|
>
|
|
Convert to long-term
|
|
</Button>
|
|
) : null}
|
|
{canResumeExitManagement ? (
|
|
<Button
|
|
type="button"
|
|
onClick={() => void handleResumeExitManagement(entry)}
|
|
variant="outline"
|
|
size="sm"
|
|
className="uppercase tracking-[0.18em]"
|
|
>
|
|
Resume exit management
|
|
</Button>
|
|
) : null}
|
|
<Button
|
|
type="button"
|
|
onClick={() => handleEdit(entry)}
|
|
variant="outline"
|
|
size="sm"
|
|
className={isEditing ? 'border-emerald-500/30 text-emerald-700 dark:text-emerald-300' : 'uppercase tracking-[0.18em]'}
|
|
>
|
|
<span className="inline-flex items-center gap-2">
|
|
<Pencil size={14} />
|
|
Edit
|
|
</span>
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={() => void handleDelete(entryId)}
|
|
variant="destructive"
|
|
size="sm"
|
|
className="uppercase tracking-[0.18em]"
|
|
>
|
|
<span className="inline-flex items-center gap-2">
|
|
<Trash2 size={14} />
|
|
Delete
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3 text-[11px] uppercase tracking-[0.2em] text-[var(--muted-foreground)]">
|
|
<span className="rounded-full border border-[var(--border)] px-3 py-1">
|
|
{formatSetupStatus(entry.status)}
|
|
</span>
|
|
<span className="rounded-full border border-[var(--border)] px-3 py-1">
|
|
{formatHoldingMode(entry.holding_mode)}
|
|
</span>
|
|
<span className="rounded-full border border-[var(--border)] px-3 py-1">
|
|
{formatAutomationState(entry)}
|
|
</span>
|
|
{runtimeSnapshot ? (
|
|
<span className={`rounded-full border px-3 py-1 ${statusToneClasses(runtimeSnapshot.tone)}`}>
|
|
{runtimeSnapshot.label}
|
|
</span>
|
|
) : null}
|
|
<span className="rounded-full border border-[var(--border)] px-3 py-1">
|
|
{String(entry.sizing_mode || '').trim().toLowerCase() === 'amount'
|
|
? `Budget $${Number(entry.amount_usd || 0).toFixed(2)}`
|
|
: `Qty ${entry.quantity || entry.filled_quantity || 0}`}
|
|
</span>
|
|
{entry.reference_price ? (
|
|
<span className="rounded-full border border-[var(--border)] px-3 py-1">
|
|
Ref {Number(entry.reference_price).toFixed(4)}
|
|
</span>
|
|
) : null}
|
|
{entry.entry_price ? (
|
|
<span className="rounded-full border border-[var(--border)] px-3 py-1">
|
|
Entry {Number(entry.entry_price).toFixed(4)}
|
|
</span>
|
|
) : null}
|
|
{(runtimeSnapshot?.tradeId || entry.linked_trade_id) ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => void copyIdentifier('trade', String(runtimeSnapshot?.tradeId || entry.linked_trade_id))}
|
|
className="rounded-full border border-[var(--border)] px-3 py-1 transition hover:border-[var(--primary)] hover:text-[var(--foreground)]"
|
|
>
|
|
{copiedKey === `trade:${String(runtimeSnapshot?.tradeId || entry.linked_trade_id)}`
|
|
? 'Trade copied'
|
|
: `Trade ${String(runtimeSnapshot?.tradeId || entry.linked_trade_id).slice(0, 18)}…`}
|
|
</button>
|
|
) : null}
|
|
{runtimeSnapshot?.orderId ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => void copyIdentifier('order', runtimeSnapshot.orderId)}
|
|
className="rounded-full border border-[var(--border)] px-3 py-1 transition hover:border-[var(--primary)] hover:text-[var(--foreground)]"
|
|
>
|
|
{copiedKey === `order:${runtimeSnapshot.orderId}`
|
|
? 'Order copied'
|
|
: `Order ${runtimeSnapshot.orderId.slice(0, 12)}…`}
|
|
</button>
|
|
) : null}
|
|
{updatedAt ? (
|
|
<span className="rounded-full border border-[var(--border)] px-3 py-1 normal-case tracking-normal">
|
|
Updated {updatedAt}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-2 md:grid-cols-5">
|
|
{SIMPLE_TIMELINE_STEPS.map((step) => {
|
|
const complete = isTimelineStepComplete(runtimeSnapshot?.stage, step);
|
|
const isCurrent = runtimeSnapshot?.stage === step;
|
|
return (
|
|
<div
|
|
key={step}
|
|
className={`rounded-2xl border px-3 py-2 text-[11px] font-semibold ${
|
|
isCurrent
|
|
? 'border-[var(--primary)] bg-[var(--accent-soft)] text-[var(--primary)]'
|
|
: complete
|
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
|
|
: 'border-[var(--border)] bg-[var(--background)] text-[var(--muted-foreground)]'
|
|
}`}
|
|
>
|
|
{formatTimelineStepLabel(step)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="mt-4 rounded-2xl border border-[var(--border)] bg-[var(--background)] px-4 py-3 text-sm text-[var(--muted-foreground)]">
|
|
<span className="font-semibold text-[var(--foreground)]">Next action:</span>{' '}
|
|
{nextActionText}
|
|
</div>
|
|
|
|
{eventHistory.length > 0 && (
|
|
<div className="mt-4 rounded-2xl border border-[var(--border)] bg-[var(--background)] px-4 py-4">
|
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
<div className="text-sm font-semibold text-[var(--foreground)]">Recent activity</div>
|
|
<div className="text-[11px] uppercase tracking-[0.18em] text-[var(--muted-foreground)]">
|
|
setup-level runtime history
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{eventHistory.map((event) => (
|
|
<div key={event.id} className={`rounded-2xl border px-3 py-3 ${eventSeverityClasses(event.severity)}`}>
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<div className="text-[11px] font-black uppercase tracking-[0.18em]">
|
|
{String(event.severity || 'INFO').toUpperCase()}
|
|
</div>
|
|
<div className="text-[11px] opacity-80">
|
|
{formatEventTimestamp(event.timestamp) || 'Just now'}
|
|
</div>
|
|
</div>
|
|
<div className="mt-1 text-sm leading-6">{event.message}</div>
|
|
<div className="mt-2 flex flex-wrap gap-2 text-[11px] uppercase tracking-[0.14em] opacity-80">
|
|
{event.tradeId ? <span>Trade {event.tradeId.slice(0, 18)}…</span> : null}
|
|
{event.orderId ? <span>Order {event.orderId.slice(0, 12)}…</span> : null}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|