From ebaabaed47342dc89c261a5b35c3267a8c5cc2a0 Mon Sep 17 00:00:00 2001 From: Saravana Achu Mac Date: Sat, 4 Apr 2026 16:09:21 -0700 Subject: [PATCH] feat: move manual entries behind backend api --- backend/src/services/apiServer.ts | 68 +++++++ backend/src/services/manualEntryRepository.ts | 188 ++++++++++++++++++ web/src/components/EntryForm.dom.test.tsx | 68 +++---- web/src/components/EntryForm.tsx | 81 +------- web/src/lib/manualEntriesApi.ts | 77 +++++++ web/src/tabs/EntriesTab.dom.test.tsx | 127 +++++------- web/src/tabs/EntriesTab.tsx | 59 +++--- 7 files changed, 437 insertions(+), 231 deletions(-) create mode 100644 backend/src/services/manualEntryRepository.ts create mode 100644 web/src/lib/manualEntriesApi.ts diff --git a/backend/src/services/apiServer.ts b/backend/src/services/apiServer.ts index adea444..4632b0a 100644 --- a/backend/src/services/apiServer.ts +++ b/backend/src/services/apiServer.ts @@ -23,6 +23,11 @@ import { saveCurrentUserProfile, saveTradeProfileForUser, } from './profileRepository.js'; +import { + deleteManualEntryForUser, + listManualEntriesForUser, + saveManualEntryForUser +} from './manualEntryRepository.js'; import { mergeOrderSnapshots, mergePositionSnapshots } from './stateMerge.js'; import { OperationalEvent } from '../domain/operationalEvents.js'; import { runBacktest } from '../backtest/index.js'; @@ -1799,6 +1804,69 @@ export class ApiServer { } }); + this.app.get('/api/manual-entries', this.requireAuth, async (req, res) => { + const authUserId = (req as AuthenticatedRequest).authUserId; + if (!authUserId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + try { + const entries = await listManualEntriesForUser(authUserId, supabaseService); + res.json({ entries }); + } catch (error: any) { + res.status(500).json({ error: `Failed to load manual entries: ${error.message}` }); + } + }); + + this.app.post('/api/manual-entries', this.requireAuth, async (req, res) => { + const authUserId = (req as AuthenticatedRequest).authUserId; + if (!authUserId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + try { + const entry = await saveManualEntryForUser(authUserId, req.body || {}, supabaseService); + res.status(201).json({ entry }); + } catch (error: any) { + res.status(400).json({ error: `Failed to save manual entry: ${error.message}` }); + } + }); + + this.app.put('/api/manual-entries/:id', this.requireAuth, async (req, res) => { + const authUserId = (req as AuthenticatedRequest).authUserId; + if (!authUserId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + try { + const entry = await saveManualEntryForUser(authUserId, { + ...(req.body || {}), + stock_instance_id: String(req.params.id || '').trim() + }, supabaseService); + res.json({ entry }); + } catch (error: any) { + res.status(400).json({ error: `Failed to update manual entry: ${error.message}` }); + } + }); + + this.app.delete('/api/manual-entries/:id', this.requireAuth, async (req, res) => { + const authUserId = (req as AuthenticatedRequest).authUserId; + if (!authUserId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + try { + await deleteManualEntryForUser(authUserId, String(req.params.id || '').trim(), supabaseService); + res.json({ success: true }); + } catch (error: any) { + res.status(400).json({ error: `Failed to delete manual entry: ${error.message}` }); + } + }); + this.app.get('/api/admin/config/dynamic', this.requireAuth, this.requireAdmin, async (_req, res) => { try { const items = await listDynamicConfigEntries(supabaseService); diff --git a/backend/src/services/manualEntryRepository.ts b/backend/src/services/manualEntryRepository.ts new file mode 100644 index 0000000..284c912 --- /dev/null +++ b/backend/src/services/manualEntryRepository.ts @@ -0,0 +1,188 @@ +import { randomUUID } from 'node:crypto'; +import logger from '../utils/logger.js'; +import type { supabaseService } from './SupabaseService.js'; + +type LegacySupabaseService = typeof supabaseService; + +export interface ManualEntryRecord { + stock_instance_id: string; + symbol: string; + active: boolean; + user_id: string; + buy_price?: number | null; + sell_price?: number | null; + buy_time?: string | null; + sell_time?: string | null; + quantity?: number | null; + filled_quantity?: number | null; + notes?: string | null; + status: string; + is_crypto: boolean; + is_real_trade: boolean; + label?: string | null; + entry_price?: number | null; + gain_threshold_for_sell?: number | null; + drop_threshold_for_buy?: number | null; +} + +function getClient(legacyService?: LegacySupabaseService) { + const client = legacyService?.getClient?.(); + if (!client) { + throw new Error('Manual entry store is not configured'); + } + return client; +} + +function normalizeNullableNumber(value: unknown): number | null | undefined { + if (value === undefined) return undefined; + if (value === null || value === '') return null; + const num = Number(value); + return Number.isFinite(num) ? num : null; +} + +function normalizeNullableString(value: unknown): string | null | undefined { + if (value === undefined) return undefined; + if (value === null) return null; + const text = String(value).trim(); + return text ? text : null; +} + +function normalizeEntry(userId: string, input: Partial, existing?: ManualEntryRecord | null): ManualEntryRecord { + return { + stock_instance_id: String(input.stock_instance_id || existing?.stock_instance_id || randomUUID()), + symbol: String(input.symbol || existing?.symbol || '').trim(), + active: Boolean(input.active ?? existing?.active ?? true), + user_id: userId, + buy_price: normalizeNullableNumber(input.buy_price ?? existing?.buy_price), + sell_price: normalizeNullableNumber(input.sell_price ?? existing?.sell_price), + buy_time: normalizeNullableString(input.buy_time ?? existing?.buy_time), + sell_time: normalizeNullableString(input.sell_time ?? existing?.sell_time), + quantity: normalizeNullableNumber(input.quantity ?? existing?.quantity), + filled_quantity: normalizeNullableNumber(input.filled_quantity ?? existing?.filled_quantity), + notes: normalizeNullableString(input.notes ?? existing?.notes), + status: String(input.status || existing?.status || 'active'), + is_crypto: Boolean(input.is_crypto ?? existing?.is_crypto ?? false), + is_real_trade: Boolean(input.is_real_trade ?? existing?.is_real_trade ?? false), + label: normalizeNullableString(input.label ?? existing?.label), + entry_price: normalizeNullableNumber(input.entry_price ?? existing?.entry_price), + gain_threshold_for_sell: normalizeNullableNumber(input.gain_threshold_for_sell ?? existing?.gain_threshold_for_sell), + drop_threshold_for_buy: normalizeNullableNumber(input.drop_threshold_for_buy ?? existing?.drop_threshold_for_buy), + }; +} + +async function insertHistoryRecord(userId: string, entry: ManualEntryRecord, reason: 'Manual Log' | 'Manual Close', legacyService?: LegacySupabaseService): Promise { + const client = getClient(legacyService); + const entryPrice = Number(entry.buy_price || 0); + const exitPrice = Number(entry.sell_price || 0); + const size = Number(entry.quantity || 0); + if (!(entryPrice > 0) || !(exitPrice > 0) || !(size > 0)) { + return; + } + + const pnl = (exitPrice - entryPrice) * size; + const pnlPercent = entryPrice > 0 ? ((exitPrice - entryPrice) / entryPrice) * 100 : 0; + + const { error } = await client + .from('trade_history') + .insert([{ + user_id: userId, + symbol: entry.symbol, + side: 'BUY', + entry_price: entryPrice, + exit_price: exitPrice, + size, + pnl, + pnl_percent: pnlPercent, + source: 'MANUAL', + reason, + timestamp: Date.now() + }]); + + if (error) { + logger.warn(`[ManualEntries] Failed to insert history row: ${error.message}`); + } +} + +export async function listManualEntriesForUser(userId: string, legacyService?: LegacySupabaseService): Promise { + const client = getClient(legacyService); + const { data, error } = await client + .from('stocks') + .select('*') + .eq('user_id', userId) + .order('created_at', { ascending: false }); + + if (error) { + throw new Error(error.message); + } + + return Array.isArray(data) ? data as ManualEntryRecord[] : []; +} + +export async function saveManualEntryForUser( + userId: string, + input: Partial, + legacyService?: LegacySupabaseService +): Promise { + const client = getClient(legacyService); + const entryId = String(input.stock_instance_id || '').trim(); + let existing: ManualEntryRecord | null = null; + + if (entryId) { + const { data, error } = await client + .from('stocks') + .select('*') + .eq('stock_instance_id', entryId) + .eq('user_id', userId) + .maybeSingle(); + if (error) { + throw new Error(error.message); + } + existing = data as ManualEntryRecord | null; + } + + const normalized = normalizeEntry(userId, input, existing); + if (!normalized.symbol) { + throw new Error('Symbol is required'); + } + + if (existing) { + const { error } = await client + .from('stocks') + .update(normalized) + .eq('stock_instance_id', normalized.stock_instance_id) + .eq('user_id', userId); + if (error) { + throw new Error(error.message); + } + + if (normalized.sell_price && !existing.sell_price && normalized.buy_price) { + await insertHistoryRecord(userId, normalized, 'Manual Close', legacyService); + } + return normalized; + } + + const { error } = await client + .from('stocks') + .insert([normalized]); + if (error) { + throw new Error(error.message); + } + + if (normalized.sell_price && normalized.buy_price) { + await insertHistoryRecord(userId, normalized, 'Manual Log', legacyService); + } + return normalized; +} + +export async function deleteManualEntryForUser(userId: string, entryId: string, legacyService?: LegacySupabaseService): Promise { + const client = getClient(legacyService); + const { error } = await client + .from('stocks') + .delete() + .eq('stock_instance_id', entryId) + .eq('user_id', userId); + + if (error) { + throw new Error(error.message); + } +} diff --git a/web/src/components/EntryForm.dom.test.tsx b/web/src/components/EntryForm.dom.test.tsx index af99b51..ef3d9c9 100644 --- a/web/src/components/EntryForm.dom.test.tsx +++ b/web/src/components/EntryForm.dom.test.tsx @@ -3,23 +3,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { EntryForm } from './EntryForm'; -import { tableNameStocks } from '../lib/const'; const { getSessionMock, - stocksInsertMock, - stocksUpdateEqMock, - stocksUpdateMock, - historyInsertMock, - fromMock, + createManualEntryMock, + updateManualEntryMock, authMock } = vi.hoisted(() => ({ getSessionMock: vi.fn(), - stocksInsertMock: vi.fn(), - stocksUpdateEqMock: vi.fn(), - stocksUpdateMock: vi.fn(), - historyInsertMock: vi.fn(), - fromMock: vi.fn(), + createManualEntryMock: vi.fn(), + updateManualEntryMock: vi.fn(), authMock: { user: { id: 'user-1' } as any } })); @@ -31,48 +24,29 @@ vi.mock('../lib/supabaseClient', () => ({ supabase: { auth: { getSession: getSessionMock - }, - from: fromMock + } } })); +vi.mock('../lib/manualEntriesApi', () => ({ + createManualEntry: createManualEntryMock, + updateManualEntry: updateManualEntryMock +})); + describe('EntryForm DOM flow', () => { const alertMock = vi.fn(); beforeEach(() => { vi.clearAllMocks(); - stocksInsertMock.mockReset(); - stocksUpdateEqMock.mockReset(); - stocksUpdateMock.mockReset(); - historyInsertMock.mockReset(); + createManualEntryMock.mockReset(); + updateManualEntryMock.mockReset(); getSessionMock.mockReset(); - fromMock.mockReset(); authMock.user = { id: 'user-1' }; - stocksInsertMock.mockResolvedValue({ error: null }); - stocksUpdateEqMock.mockResolvedValue({ error: null }); - stocksUpdateMock.mockReturnValue({ eq: stocksUpdateEqMock }); - historyInsertMock.mockResolvedValue({ error: null }); + createManualEntryMock.mockResolvedValue({}); + updateManualEntryMock.mockResolvedValue({}); getSessionMock.mockResolvedValue({ data: { session: null } }); - fromMock.mockImplementation((table: string) => { - if (table === tableNameStocks) { - return { - insert: stocksInsertMock, - update: stocksUpdateMock - }; - } - if (table === 'trade_history') { - return { - insert: historyInsertMock - }; - } - return { - insert: vi.fn().mockResolvedValue({ error: null }), - update: vi.fn(() => ({ eq: vi.fn().mockResolvedValue({ error: null }) })) - }; - }); - vi.stubGlobal('fetch', vi.fn()); vi.stubGlobal('confirm', vi.fn(() => true)); vi.stubGlobal('alert', alertMock); @@ -91,7 +65,7 @@ describe('EntryForm DOM flow', () => { await user.click(screen.getByRole('button', { name: 'Add' })); await waitFor(() => { - expect(stocksInsertMock).toHaveBeenCalled(); + expect(createManualEntryMock).toHaveBeenCalled(); expect(onSuccess).toHaveBeenCalled(); }); }); @@ -127,13 +101,17 @@ describe('EntryForm DOM flow', () => { await user.click(screen.getByRole('button', { name: 'Add' })); await waitFor(() => { - expect(historyInsertMock).toHaveBeenCalledTimes(1); + expect(createManualEntryMock).toHaveBeenCalledTimes(1); }); - expect(historyInsertMock.mock.calls[0][0][0].reason).toBe('Manual Log'); + expect(createManualEntryMock.mock.calls[0][0]).toEqual(expect.objectContaining({ + symbol: 'BTC/USD', + buy_price: 100, + sell_price: 110 + })); }); it('handles database error by alerting user', async () => { - stocksInsertMock.mockResolvedValue({ error: { message: 'DB Error' } }); + createManualEntryMock.mockRejectedValueOnce(new Error('DB Error')); const user = userEvent.setup(); render(); @@ -152,6 +130,6 @@ describe('EntryForm DOM flow', () => { await user.type(screen.getByPlaceholderText('BTC/USD'), 'BTC/USD'); await user.click(screen.getByRole('button', { name: 'Add' })); - expect(stocksInsertMock).not.toHaveBeenCalled(); + expect(createManualEntryMock).not.toHaveBeenCalled(); }); }); diff --git a/web/src/components/EntryForm.tsx b/web/src/components/EntryForm.tsx index 646b66b..6821ea0 100644 --- a/web/src/components/EntryForm.tsx +++ b/web/src/components/EntryForm.tsx @@ -2,8 +2,8 @@ import { useState, useEffect } from 'react'; import type { FormEvent } from 'react'; import { supabase } from '../lib/supabaseClient'; import { useAuth } from '../components/AuthContext'; -import { tableNameStocks } from '../lib/const'; import { tradingRuntime } from '../lib/runtime'; +import { createManualEntry, updateManualEntry } from '../lib/manualEntriesApi'; interface EntryFormProps { onSuccess: () => void; @@ -143,88 +143,13 @@ export function EntryForm({ onSuccess, initialData }: EntryFormProps) { throw new Error(`Execution Failed: ${resData.error}`); } - // If success, we continue to save to DB as usual for journaling alert(`✅ Trade Executed! Order ID: ${resData.orderId}`); } if (initialData?.stock_instance_id) { - // Update - const { error } = await supabase - .from(tableNameStocks) - .update(payload) - .eq('stock_instance_id', initialData.stock_instance_id); - - if (error) throw error; - - // --- NEW: Log to Trade History if Closing --- - // If we are adding a sell_price to an open position, we consider it "Closing" - if (payload.sell_price && !initialData.sell_price && payload.buy_price) { - const entryPrice = payload.buy_price; - const exitPrice = payload.sell_price; - const size = payload.quantity || 0; - const pnl = (exitPrice - entryPrice) * size; - const pnlPercent = ((exitPrice - entryPrice) / entryPrice) * 100; - - // Insert into trade_history - const { error: historyError } = await supabase - .from('trade_history') - .insert([{ - user_id: user.id, - symbol: payload.symbol, - side: 'BUY', - entry_price: entryPrice, - exit_price: exitPrice, - size: size, - pnl: pnl, - pnl_percent: pnlPercent, - source: 'MANUAL', - reason: 'Manual Close', - timestamp: Date.now() - }]); - - if (historyError) { - console.error("Failed to log manual trade to history:", historyError.message); - } - } - + await updateManualEntry(initialData.stock_instance_id, payload); } else { - // Insert - // Remove ID to let DB generate it - const { error } = await supabase - .from(tableNameStocks) - .insert([{ ...payload, stock_instance_id: crypto.randomUUID() }]); - - if (error) throw error; - - // --- NEW: Log to Trade History if Creating a Closed Entry (Journaling past trade) --- - if (payload.sell_price && payload.buy_price) { - const entryPrice = payload.buy_price; - const exitPrice = payload.sell_price; - const size = payload.quantity || 0; - const pnl = (exitPrice - entryPrice) * size; - const pnlPercent = ((exitPrice - entryPrice) / entryPrice) * 100; - - // Insert into trade_history - const { error: historyError } = await supabase - .from('trade_history') - .insert([{ - user_id: user.id, - symbol: payload.symbol, - side: 'BUY', - entry_price: entryPrice, - exit_price: exitPrice, - size: size, - pnl: pnl, - pnl_percent: pnlPercent, - source: 'MANUAL', - reason: 'Manual Log', - timestamp: Date.now() - }]); - - if (historyError) { - console.error("Failed to log manual trade to history:", historyError.message); - } - } + await createManualEntry({ ...payload, stock_instance_id: crypto.randomUUID() }); } onSuccess(); diff --git a/web/src/lib/manualEntriesApi.ts b/web/src/lib/manualEntriesApi.ts new file mode 100644 index 0000000..51990be --- /dev/null +++ b/web/src/lib/manualEntriesApi.ts @@ -0,0 +1,77 @@ +import { supabase } from './supabaseClient'; +import { tradingRuntime } from './runtime'; + +export interface ManualEntryPayload { + stock_instance_id?: string; + symbol: string; + active: boolean; + user_id?: string; + buy_price?: number | null; + sell_price?: number | null; + buy_time?: string | null; + sell_time?: string | null; + quantity?: number | null; + filled_quantity?: number | null; + notes?: string | null; + status: string; + is_crypto: boolean; + is_real_trade: boolean; + label?: string | null; + entry_price?: number | null; + gain_threshold_for_sell?: number | null; + drop_threshold_for_buy?: number | null; +} + +async function getAccessToken(): Promise { + const { data: sessionData } = await supabase.auth.getSession(); + const accessToken = sessionData.session?.access_token; + if (!accessToken) { + throw new Error('Not authenticated'); + } + return accessToken; +} + +async function apiRequest(path: string, init?: RequestInit): Promise { + const accessToken = await getAccessToken(); + const response = await fetch(`${tradingRuntime.tradingApiUrl}${path}`, { + ...init, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + ...(init?.headers || {}), + }, + }); + + const body = await response.json().catch(() => ({} as any)); + if (!response.ok) { + throw new Error(body?.error || `Request failed (${response.status})`); + } + return body as T; +} + +export async function fetchManualEntries(): Promise { + const response = await apiRequest<{ entries: ManualEntryPayload[] }>('/api/manual-entries'); + return Array.isArray(response.entries) ? response.entries : []; +} + +export async function createManualEntry(payload: ManualEntryPayload): Promise { + const response = await apiRequest<{ entry: ManualEntryPayload }>('/api/manual-entries', { + method: 'POST', + body: JSON.stringify(payload), + }); + return response.entry; +} + +export async function updateManualEntry(id: string, payload: ManualEntryPayload): Promise { + const response = await apiRequest<{ entry: ManualEntryPayload }>(`/api/manual-entries/${id}`, { + method: 'PUT', + body: JSON.stringify(payload), + }); + return response.entry; +} + +export async function deleteManualEntry(id: string): Promise { + await apiRequest<{ success: boolean }>(`/api/manual-entries/${id}`, { + method: 'DELETE', + }); +} diff --git a/web/src/tabs/EntriesTab.dom.test.tsx b/web/src/tabs/EntriesTab.dom.test.tsx index 5896b7b..77880a4 100644 --- a/web/src/tabs/EntriesTab.dom.test.tsx +++ b/web/src/tabs/EntriesTab.dom.test.tsx @@ -1,32 +1,35 @@ // @vitest-environment jsdom -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { EntriesTab } from './EntriesTab'; -import { tableNameStocks } from '../lib/const'; - -const { - authState, - fromMock, - confirmMock -} = vi.hoisted(() => ({ - authState: { - user: { id: 'user-1', email: 'test@demo.test' } as any, - profile: { role: 'user' } as any - }, - fromMock: vi.fn(), - confirmMock: vi.fn() -})); +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { EntriesTab } from './EntriesTab'; + +const { + authState, + confirmMock, + fetchManualEntriesMock, + deleteManualEntryMock, + createManualEntryMock +} = vi.hoisted(() => ({ + authState: { + user: { id: 'user-1', email: 'test@demo.test' } as any, + profile: { role: 'user' } as any + }, + confirmMock: vi.fn(), + fetchManualEntriesMock: vi.fn(), + deleteManualEntryMock: vi.fn(), + createManualEntryMock: vi.fn() +})); vi.mock('../components/AuthContext', () => ({ useAuth: () => authState })); -vi.mock('../lib/supabaseClient', () => ({ - supabase: { - from: fromMock - } -})); +vi.mock('../lib/manualEntriesApi', () => ({ + fetchManualEntries: fetchManualEntriesMock, + deleteManualEntry: deleteManualEntryMock, + createManualEntry: createManualEntryMock +})); vi.mock('../components/EntryForm', () => ({ EntryForm: ({ onSuccess, initialData }: any) => ( @@ -37,21 +40,7 @@ vi.mock('../components/EntryForm', () => ({ ) })); -const createMockQuery = (data: any, error: any = null) => { - const chain: any = { - select: vi.fn(), - eq: vi.fn(), - order: vi.fn(), - then: vi.fn() - }; - chain.select.mockReturnValue(chain); - chain.eq.mockReturnValue(chain); - chain.order.mockReturnValue(chain); - chain.then.mockImplementation((cb: any) => Promise.resolve({ data, error }).then(cb)); - return chain; -}; - -describe('EntriesTab master suite', () => { +describe('EntriesTab master suite', () => { const mockEntry = { stock_instance_id: 'entry-1', symbol: 'BTC/USDT', @@ -75,26 +64,16 @@ describe('EntriesTab master suite', () => { settings: { executionMode: 'Paper' } }; - beforeEach(() => { - vi.clearAllMocks(); - authState.user = { id: 'user-1', email: 'test@demo.test' }; - authState.profile = { role: 'user' }; - vi.stubGlobal('confirm', confirmMock); - vi.stubGlobal('crypto', { randomUUID: () => 'uuid-123' }); - - fromMock.mockImplementation((table: string) => { - if (table === tableNameStocks) { - return { - select: () => createMockQuery([mockEntry]), - delete: () => ({ eq: () => Promise.resolve({ error: null }) }), - insert: () => Promise.resolve({ error: null }) - }; - } - return { - select: () => createMockQuery([]) - }; - }); - }); + beforeEach(() => { + vi.clearAllMocks(); + authState.user = { id: 'user-1', email: 'test@demo.test' }; + authState.profile = { role: 'user' }; + vi.stubGlobal('confirm', confirmMock); + vi.stubGlobal('crypto', { randomUUID: () => 'uuid-123' }); + fetchManualEntriesMock.mockResolvedValue([mockEntry]); + deleteManualEntryMock.mockResolvedValue(undefined); + createManualEntryMock.mockResolvedValue({}); + }); it('handles entry drawer interactions', async () => { const user = userEvent.setup(); @@ -109,14 +88,14 @@ describe('EntriesTab master suite', () => { it('derives diverse entry states (LOCKED, BLOCKED, SUBMITTED, CONFIRMED, ORPHAN)', async () => { const user = userEvent.setup(); - const entries = [ - { stock_instance_id: 's1', symbol: 'S1', active: true, status: 'active', is_real_trade: false }, - { stock_instance_id: 's2', symbol: 'S2', active: false, status: 'cooldown', is_real_trade: false }, // Use 'cooldown' instead of 'blocked' because 'blocked' contains 'lock' + const entries = [ + { stock_instance_id: 's1', symbol: 'S1', active: true, status: 'active', is_real_trade: false }, + { stock_instance_id: 's2', symbol: 'S2', active: false, status: 'cooldown', is_real_trade: false }, // Use 'cooldown' instead of 'blocked' because 'blocked' contains 'lock' { stock_instance_id: 's3', symbol: 'S3', active: false, status: 'submitted', is_real_trade: false }, { stock_instance_id: 's4', symbol: 'S4', active: false, status: 'confirmed', is_real_trade: false }, - { stock_instance_id: 's5', symbol: 'S5', active: false, status: 'orphan', is_real_trade: false }, - ]; - fromMock.mockImplementation(() => ({ select: () => createMockQuery(entries) })); + { stock_instance_id: 's5', symbol: 'S5', active: false, status: 'orphan', is_real_trade: false }, + ]; + fetchManualEntriesMock.mockResolvedValue(entries); render(); @@ -138,13 +117,13 @@ describe('EntriesTab master suite', () => { await waitFor(() => expect(screen.getByText('BTC/USDT')).toBeInTheDocument()); const card = screen.getByText('BTC/USDT').closest('.group'); - const buttons = within(card as HTMLElement).getAllByRole('button'); - - await fireEvent.click(buttons[1]); - expect(fromMock).toHaveBeenCalledWith(tableNameStocks); - - confirmMock.mockReturnValue(true); - await fireEvent.click(buttons[2]); - expect(fromMock).toHaveBeenCalledWith(tableNameStocks); - }); -}); + const buttons = within(card as HTMLElement).getAllByRole('button'); + + await fireEvent.click(buttons[1]); + expect(createManualEntryMock).toHaveBeenCalled(); + + confirmMock.mockReturnValue(true); + await fireEvent.click(buttons[2]); + expect(deleteManualEntryMock).toHaveBeenCalledWith('entry-1'); + }); +}); diff --git a/web/src/tabs/EntriesTab.tsx b/web/src/tabs/EntriesTab.tsx index 2d9dc62..d08e518 100644 --- a/web/src/tabs/EntriesTab.tsx +++ b/web/src/tabs/EntriesTab.tsx @@ -1,11 +1,10 @@ -import { useEffect, useState } from 'react'; -import { supabase } from '../lib/supabaseClient'; -import { useAuth } from '../components/AuthContext'; -import { tableNameStocks } from '../lib/const'; -import { EntryForm } from '../components/EntryForm'; -import { Pencil, Trash2, Copy as CopyIcon, Search, Plus, Filter, LayoutGrid, Clock, ShieldCheck, Activity } from 'lucide-react'; -import type { BotState } from '../hooks/useWebSocket'; -import { DEFAULT_BOT_STATE } from '../hooks/useWebSocket'; +import { useEffect, useState } from 'react'; +import { useAuth } from '../components/AuthContext'; +import { EntryForm } from '../components/EntryForm'; +import { Pencil, Trash2, Copy as CopyIcon, Search, Plus, Filter, LayoutGrid, Clock, ShieldCheck, Activity } from 'lucide-react'; +import type { BotState } from '../hooks/useWebSocket'; +import { DEFAULT_BOT_STATE } from '../hooks/useWebSocket'; +import { createManualEntry, deleteManualEntry, fetchManualEntries, type ManualEntryPayload } from '../lib/manualEntriesApi'; interface Entry { stock_instance_id: string; @@ -105,20 +104,14 @@ export const EntriesTab = ({ botState = DEFAULT_BOT_STATE }: EntriesTabProps) => // Filter Logic const filteredEntries = filterEntriesByTab(entries, activeTab); - const fetchEntries = async () => { - if (!user) return; - try { - const { data, error } = await supabase - .from(tableNameStocks) - .select("*") - .eq("user_id", user.id) - .order("created_at", { ascending: false }); - - if (error) throw error; - setEntries(data || []); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - console.error("Error fetching entries:", message); + const fetchEntries = async () => { + if (!user) return; + try { + const data = await fetchManualEntries(); + setEntries(data as Entry[]); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.error("Error fetching entries:", message); } }; @@ -126,18 +119,16 @@ export const EntriesTab = ({ botState = DEFAULT_BOT_STATE }: EntriesTabProps) => fetchEntries(); }, [user]); - const handleDelete = async (id: string) => { - if (!confirm('⚠️ CRITICAL: Permanently delete this entry from neural watchlist?')) return; - await supabase.from(tableNameStocks).delete().eq('stock_instance_id', id); - fetchEntries(); - }; - - const handleClone = async (entry: Entry) => { - await supabase.from(tableNameStocks).insert([{ - ...buildClonedEntryPayload(entry, crypto.randomUUID()) - }]); - fetchEntries(); - }; + const handleDelete = async (id: string) => { + if (!confirm('⚠️ CRITICAL: Permanently delete this entry from neural watchlist?')) return; + await deleteManualEntry(id); + fetchEntries(); + }; + + const handleClone = async (entry: Entry) => { + await createManualEntry(buildClonedEntryPayload(entry, crypto.randomUUID()) as unknown as ManualEntryPayload); + fetchEntries(); + }; const entryCards = filteredEntries.map((entry) => { const entryState = deriveEntryState(entry, botState);