751 lines
29 KiB
TypeScript
751 lines
29 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import type { FormEvent } from 'react';
|
|
import { Pencil, RefreshCw, Trash2 } from 'lucide-react';
|
|
import { useAppContext } from '../context/AppContext';
|
|
import { fetchChartBars } 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;
|
|
quantity: 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;
|
|
};
|
|
|
|
const SIMPLE_AUTO_PROFILE_NAME = 'Simple Auto Profile';
|
|
const SIMPLE_AUTO_PROFILE_KEY = SIMPLE_AUTO_PROFILE_NAME.toLowerCase();
|
|
|
|
const DEFAULT_DRAFT: SimpleSetupDraft = {
|
|
symbol: '',
|
|
side: 'buy',
|
|
quantity: '',
|
|
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 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 computeBuyTriggerPrice(draft: SimpleSetupDraft): number | null {
|
|
const currentMarketPrice = parsePositiveNumber(draft.currentMarketPrice);
|
|
const dropValue = parsePositiveNumber(draft.dropValue);
|
|
if (!currentMarketPrice || !dropValue) 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;
|
|
}): 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('Current market price is unavailable. Refresh market data and try again.');
|
|
}
|
|
|
|
const quantity = parsePositiveNumber(input.draft.quantity);
|
|
if (!quantity) {
|
|
throw new Error('Quantity is required');
|
|
}
|
|
|
|
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;
|
|
|
|
if (side === 'sell' && !holding) {
|
|
throw new Error('Sell setups require an existing Simple holding for this symbol.');
|
|
}
|
|
|
|
if (side === 'buy' && !parsePositiveNumber(input.draft.dropValue)) {
|
|
throw new Error('Drop trigger is required for buy setups');
|
|
}
|
|
|
|
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 : 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' ? parsePositiveNumber(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,
|
|
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 dropText = 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`;
|
|
|
|
return [
|
|
`Buy ${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 a Simple holding already exists.`;
|
|
}
|
|
|
|
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 buildDraftFromEntry(entry: ManualEntryPayload): SimpleSetupDraft {
|
|
return {
|
|
symbol: String(entry.symbol || '').trim().toUpperCase(),
|
|
side: normalizeSetupSide(entry.simple_side),
|
|
quantity: entry.quantity ? String(entry.quantity) : '',
|
|
currentMarketPrice: entry.reference_price ? Number(entry.reference_price).toFixed(4) : '',
|
|
dropMode: normalizeMode(entry.drop_trigger_mode, 'percent'),
|
|
dropValue: entry.drop_threshold_for_buy ? String(entry.drop_threshold_for_buy) : '',
|
|
profitMode: normalizeMode(entry.profit_target_mode, 'percent'),
|
|
profitValue: entry.gain_threshold_for_sell ? String(entry.gain_threshold_for_sell) : '',
|
|
notes: String(entry.notes || ''),
|
|
};
|
|
}
|
|
|
|
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 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 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);
|
|
|
|
if (side === 'buy') {
|
|
const triggerPrice = computeBuyTriggerPrice(buildDraftFromEntry(entry));
|
|
const dropText = dropMode === 'dollar'
|
|
? `$${dropValue.toFixed(2)} below`
|
|
: `${dropValue}% below`;
|
|
const profitText = profitMode === 'dollar'
|
|
? `$${profitValue.toFixed(2)} above purchase`
|
|
: `${profitValue}% above purchase`;
|
|
return `Buy ${symbol} ${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 ${symbol} at ${profitText}${profitTargetPrice ? ` (${profitTargetPrice.toFixed(4)})` : ''}.`;
|
|
}
|
|
|
|
export function SimpleView() {
|
|
const { botState } = useAppContext();
|
|
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 [message, setMessage] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const normalizedSymbol = draft.symbol.trim().toUpperCase();
|
|
const livePrice = normalizedSymbol ? Number(botState.symbols?.[normalizedSymbol]?.price || 0) : 0;
|
|
const simpleAutoProfile = useMemo(
|
|
() => profiles.find((profile) => matchesSimpleAutoProfile(profile)) || null,
|
|
[profiles],
|
|
);
|
|
|
|
const simpleHoldings = useMemo(() => {
|
|
const simpleProfileId = simpleAutoProfile?.id;
|
|
return botState.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 matchingHolding = useMemo(
|
|
() => simpleHoldings.find((holding) => holding.symbol === normalizedSymbol) || null,
|
|
[simpleHoldings, normalizedSymbol],
|
|
);
|
|
|
|
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;
|
|
setDraft((prev) => (
|
|
prev.currentMarketPrice === livePrice.toFixed(4)
|
|
? prev
|
|
: { ...prev, currentMarketPrice: livePrice.toFixed(4) }
|
|
));
|
|
}, [livePrice]);
|
|
|
|
useEffect(() => {
|
|
if (!normalizedSymbol || livePrice > 0 || draft.currentMarketPrice.trim() || loadingPrice) {
|
|
return;
|
|
}
|
|
|
|
void handleLoadMarketPrice();
|
|
}, [normalizedSymbol, livePrice]);
|
|
|
|
const previewText = useMemo(
|
|
() => buildPreviewText(draft, draft.side === 'sell' ? matchingHolding : null),
|
|
[draft, matchingHolding],
|
|
);
|
|
|
|
function updateDraft<K extends keyof SimpleSetupDraft>(key: K, value: SimpleSetupDraft[K]) {
|
|
setDraft((prev) => ({ ...prev, [key]: value }));
|
|
}
|
|
|
|
async function refreshSetupList() {
|
|
const [profileRows, entryRows] = await Promise.all([
|
|
fetchTradeProfiles(),
|
|
fetchManualEntries(),
|
|
]);
|
|
setProfiles(profileRows);
|
|
setSavedSetups(normalizeSimpleEntries(entryRows));
|
|
}
|
|
|
|
async function handleLoadMarketPrice() {
|
|
if (!normalizedSymbol) return;
|
|
setLoadingPrice(true);
|
|
setError(null);
|
|
setMessage(null);
|
|
|
|
try {
|
|
if (livePrice > 0) {
|
|
updateDraft('currentMarketPrice', livePrice.toFixed(4));
|
|
return;
|
|
}
|
|
|
|
const bars = await fetchChartBars(normalizedSymbol, '1D');
|
|
const lastClose = Number(bars?.[bars.length - 1]?.close || 0);
|
|
if (Number.isFinite(lastClose) && lastClose > 0) {
|
|
updateDraft('currentMarketPrice', lastClose.toFixed(4));
|
|
} else {
|
|
throw new Error('No recent market price available');
|
|
}
|
|
} catch (err: any) {
|
|
setError(err?.message ?? 'Failed to load market price');
|
|
} finally {
|
|
setLoadingPrice(false);
|
|
}
|
|
}
|
|
|
|
async function handleSubmit(e: FormEvent) {
|
|
e.preventDefault();
|
|
setSubmitting(true);
|
|
setError(null);
|
|
setMessage(null);
|
|
|
|
try {
|
|
const payload = buildSimpleSetupPayload({
|
|
draft,
|
|
existingId: editingSetupId || undefined,
|
|
holding: draft.side === 'sell' ? matchingHolding : null,
|
|
});
|
|
|
|
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);
|
|
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 || ''));
|
|
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);
|
|
setDraft(DEFAULT_DRAFT);
|
|
}
|
|
await refreshSetupList();
|
|
} catch (err: any) {
|
|
setError(err?.message ?? 'Failed to delete Simple setup');
|
|
}
|
|
}
|
|
|
|
const saveButtonLabel = editingSetupId ? 'Update setup' : 'Save setup';
|
|
const saveButtonDisabled = submitting || loadingPrice || (draft.side === 'sell' && !matchingHolding);
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<PageHeader
|
|
title="Simple"
|
|
description="Create saved dip-buy and profit-exit setups with the same workspace patterns used across Research, Markets, and Settings."
|
|
/>
|
|
|
|
<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>Saved trigger workflow, not an immediate broker order</CardDescription>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
onClick={() => {
|
|
setEditingSetupId(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-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) => setDraft((prev) => ({
|
|
...prev,
|
|
symbol: e.target.value.toUpperCase(),
|
|
currentMarketPrice: '',
|
|
}))}
|
|
placeholder="AAPL"
|
|
/>
|
|
</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">Sell existing Simple 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)]"
|
|
/>
|
|
</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">
|
|
<label className="space-y-2">
|
|
<span className="text-[11px] font-black uppercase tracking-[0.24em] text-zinc-500">
|
|
{draft.side === 'buy' ? 'Planned quantity' : 'Holding size'}
|
|
</span>
|
|
<Input
|
|
value={draft.side === 'sell' && matchingHolding ? String(matchingHolding.size) : draft.quantity}
|
|
onChange={(e) => updateDraft('quantity', e.target.value)}
|
|
readOnly={draft.side === 'sell' && !!matchingHolding}
|
|
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' ? '5.00' : '8'}
|
|
/>
|
|
</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 ${
|
|
matchingHolding
|
|
? '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'
|
|
}`}>
|
|
{matchingHolding
|
|
? `Simple holding ready: ${matchingHolding.symbol} · ${matchingHolding.size} shares at ${matchingHolding.entryPrice.toFixed(4)}`
|
|
: 'No existing Simple holding found for this symbol. Sell setups only arm against a current Simple 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 and update armed simple workflows in the same layout style used across the app.</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;
|
|
return (
|
|
<div key={entryId} className="rounded-[1.5rem] border border-[var(--border)] bg-[var(--card-elevated)] p-5">
|
|
<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">
|
|
<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>
|
|
{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}
|
|
{entry.linked_trade_id ? (
|
|
<span className="rounded-full border border-[var(--border)] px-3 py-1">
|
|
Trade linked
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|