diff --git a/components/StockSearchModal.tsx b/components/StockSearchModal.tsx new file mode 100644 index 0000000..a52985d --- /dev/null +++ b/components/StockSearchModal.tsx @@ -0,0 +1,140 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { X, Search, Star, Globe, Building2, TrendingUp, TrendingDown, ChevronRight } from 'lucide-react'; +import { StockItem, WatchlistGroup, MarketType } from '../types'; +import { DbService } from '../services/dbService'; +import { MOCK_STOCKS } from '../constants'; + +interface StockSearchModalProps { + onClose: () => void; + onSelect: (stock: StockItem) => void; + currentMarket: MarketType; +} + +const StockSearchModal: React.FC = ({ onClose, onSelect, currentMarket }) => { + const [searchQuery, setSearchQuery] = useState(''); + const [groups, setGroups] = useState([]); + const [selectedGroupId, setSelectedGroupId] = useState('ALL'); + const dbService = useMemo(() => new DbService(), []); + + useEffect(() => { + const fetchGroups = async () => { + const data = await dbService.getWatchlistGroups(); + setGroups(data.filter(g => g.market === currentMarket)); + }; + fetchGroups(); + }, [dbService, currentMarket]); + + const filteredStocks = useMemo(() => { + let base = MOCK_STOCKS.filter(s => s.market === currentMarket); + + if (selectedGroupId !== 'ALL') { + const group = groups.find(g => g.id === selectedGroupId); + if (group) { + base = base.filter(s => group.codes.includes(s.code)); + } + } + + if (searchQuery) { + const lowerQuery = searchQuery.toLowerCase(); + base = base.filter(s => + s.name.toLowerCase().includes(lowerQuery) || + s.code.toLowerCase().includes(lowerQuery) + ); + } + + return base; + }, [searchQuery, selectedGroupId, groups, currentMarket]); + + return ( +
+
+ + {/* 사이드바: 그룹 목록 */} +
+
+

+ 관심 그룹 +

+
+
+ + {groups.map(group => ( + + ))} +
+
+ + {/* 메인: 검색 및 목록 */} +
+
+
+ + setSearchQuery(e.target.value)} + /> +
+ +
+ +
+ {filteredStocks.length > 0 ? ( + filteredStocks.map(stock => ( +
onSelect(stock)} + className="p-3 rounded-2xl border border-slate-50 hover:border-blue-100 hover:bg-blue-50/30 cursor-pointer transition-all flex items-center justify-between group" + > +
+
+ {stock.name[0]} +
+
+
{stock.name}
+ {stock.code} +
+
+
+

+ {stock.market === MarketType.DOMESTIC ? stock.price.toLocaleString() : `$${stock.price}`} +

+
= 0 ? 'text-rose-500' : 'text-blue-600'}`}> + {stock.changePercent >= 0 ? : } + {Math.abs(stock.changePercent)}% +
+
+
+ )) + ) : ( +
+ +

검색 결과가 없습니다.

+
+ )} +
+
+
+
+ ); +}; + +export default StockSearchModal; diff --git a/components/TradeModal.tsx b/components/TradeModal.tsx index 7eef3b9..b4e9647 100644 --- a/components/TradeModal.tsx +++ b/components/TradeModal.tsx @@ -1,8 +1,9 @@ import React, { useState, useEffect, useMemo } from 'react'; -import { X, RotateCcw, ChevronDown, Zap, Plus, Minus, Calendar, ToggleLeft, ToggleRight, CheckSquare, Square, TrendingUp, TrendingDown, Wallet, Target, ShieldAlert, BadgePercent, Save, PlayCircle, Info, BarChart3, Maximize2, Circle, CheckCircle2 } from 'lucide-react'; +import { X, RotateCcw, ChevronDown, Zap, Plus, Minus, Calendar, ToggleLeft, ToggleRight, CheckSquare, Square, TrendingUp, TrendingDown, Wallet, Target, ShieldAlert, BadgePercent, Save, PlayCircle, Info, BarChart3, Maximize2, Circle, CheckCircle2, Search } from 'lucide-react'; import { StockItem, OrderType, MarketType, ReservedOrder, TradeOrder } from '../types'; import { DbService, HoldingItem } from '../services/dbService'; +import StockSearchModal from './StockSearchModal'; interface TradeModalProps { stock: StockItem; @@ -14,7 +15,9 @@ interface TradeModalProps { type StrategyType = 'NONE' | 'PROFIT' | 'LOSS' | 'TRAILING_STOP'; -const TradeModal: React.FC = ({ stock, type: initialType, onClose, onExecute, onImmediateOrder }) => { +const TradeModal: React.FC = ({ stock: initialStock, type: initialType, onClose, onExecute, onImmediateOrder }) => { + const [localStock, setLocalStock] = useState(initialStock); + const [isSearchOpen, setIsSearchOpen] = useState(false); const [orderType, setOrderType] = useState(initialType); const isBuyMode = orderType === OrderType.BUY; @@ -25,21 +28,21 @@ const TradeModal: React.FC = ({ stock, type: initialType, onClo useEffect(() => { const fetchData = async () => { const holdings = await dbService.getHoldings(); - const currentHolding = holdings.find(h => h.code === stock.code) || null; + const currentHolding = holdings.find(h => h.code === localStock.code) || null; setHolding(currentHolding); const summary = await dbService.getAccountSummary(); setBuyingPower(summary.buyingPower); }; fetchData(); - }, [stock.code, dbService, orderType]); + }, [localStock.code, dbService, orderType]); const plInfo = useMemo(() => { if (!holding) return null; - const pl = (stock.price - holding.avgPrice) * holding.quantity; - const percent = ((stock.price - holding.avgPrice) / holding.avgPrice) * 100; + const pl = (localStock.price - holding.avgPrice) * holding.quantity; + const percent = ((localStock.price - holding.avgPrice) / holding.avgPrice) * 100; return { pl, percent }; - }, [holding, stock.price]); + }, [holding, localStock.price]); const [monitoringEnabled, setMonitoringEnabled] = useState(false); const [immediateStart, setImmediateStart] = useState(true); @@ -54,7 +57,7 @@ const TradeModal: React.FC = ({ stock, type: initialType, onClo // TS 내부 감시 시작 조건 (Trigger) const [triggerEnabled, setTriggerEnabled] = useState(false); const [triggerType, setTriggerType] = useState<'CURRENT' | 'HIGH' | 'LOW' | 'VOLUME'>('CURRENT'); - const [triggerValue, setTriggerValue] = useState(stock.price); + const [triggerValue, setTriggerValue] = useState(localStock.price); const [monCondition, setMonCondition] = useState<'ABOVE' | 'BELOW'>(isBuyMode ? 'BELOW' : 'ABOVE'); // 자동 조건 결정 로직 @@ -80,13 +83,13 @@ const TradeModal: React.FC = ({ stock, type: initialType, onClo } applyAutoCondition(triggerType, isBuyMode); if (triggerType !== 'VOLUME') { - setTriggerValue(stock.price); + setTriggerValue(localStock.price); } - }, [isBuyMode, stock.price]); + }, [isBuyMode, localStock.price]); const [monTargetUnit, setMonTargetUnit] = useState<'PRICE' | 'PERCENT' | 'AMOUNT'>('PRICE'); - const [profitValue, setProfitValue] = useState(stock.price * 1.1); - const [lossValue, setLossValue] = useState(stock.price * 0.9); + const [profitValue, setProfitValue] = useState(localStock.price * 1.1); + const [lossValue, setLossValue] = useState(localStock.price * 0.9); const [expiryDays, setExpiryDays] = useState(1); const [customExpiryDate, setCustomExpiryDate] = useState( @@ -99,14 +102,14 @@ const TradeModal: React.FC = ({ stock, type: initialType, onClo const [quantity, setQuantity] = useState(1); const [quantityRatio, setQuantityRatio] = useState(isBuyMode ? 10 : 100); - const currencySymbol = stock.market === MarketType.DOMESTIC ? '원' : '$'; + const currencySymbol = localStock.market === MarketType.DOMESTIC ? '원' : '$'; const handleMaxQuantity = () => { if (quantityMode === 'RATIO') { setQuantityRatio(100); } else { if (isBuyMode) { - const maxQty = Math.floor(buyingPower / stock.price); + const maxQty = Math.floor(buyingPower / localStock.price); setQuantity(maxQty > 0 ? maxQty : 1); } else if (holding) { setQuantity(holding.quantity); @@ -120,7 +123,7 @@ const TradeModal: React.FC = ({ stock, type: initialType, onClo setActiveStrategy(isBuyMode ? 'TRAILING_STOP' : 'NONE'); setTriggerEnabled(false); setTriggerType('CURRENT'); - setTriggerValue(stock.price); + setTriggerValue(localStock.price); applyAutoCondition('CURRENT', isBuyMode); setPriceMethod('CURRENT'); setTickOffset(0); @@ -133,13 +136,13 @@ const TradeModal: React.FC = ({ stock, type: initialType, onClo if (quantityMode === 'RATIO') { if (isBuyMode) { const targetBudget = buyingPower * (quantityRatio / 100); - return Math.floor(targetBudget / stock.price) || 0; + return Math.floor(targetBudget / localStock.price) || 0; } else if (holding) { return Math.floor(holding.quantity * (quantityRatio / 100)); } } return quantity; - }, [isBuyMode, quantityMode, quantity, quantityRatio, holding, buyingPower, stock.price]); + }, [isBuyMode, quantityMode, quantity, quantityRatio, holding, buyingPower, localStock.price]); const summaryMessage = useMemo(() => { const action = isBuyMode ? '매수' : '매도'; @@ -200,24 +203,24 @@ const TradeModal: React.FC = ({ stock, type: initialType, onClo else finalExpiry = new Date(customExpiryDate); onExecute({ - stockCode: stock.code, - stockName: stock.name, + stockCode: localStock.code, + stockName: localStock.name, type: orderType, quantity: finalQuantity, monitoringType: activeStrategy === 'TRAILING_STOP' ? 'TRAILING_STOP' : 'PRICE_TRIGGER', - triggerPrice: triggerEnabled ? triggerValue : stock.price, + triggerPrice: triggerEnabled ? triggerValue : localStock.price, trailingType: tsUnit === 'PERCENT' ? 'PERCENT' : 'AMOUNT', trailingValue: tsValue, - market: stock.market, + market: localStock.market, expiryDate: finalExpiry }); } else { if (onImmediateOrder) { onImmediateOrder({ - stockCode: stock.code, - stockName: stock.name, + stockCode: localStock.code, + stockName: localStock.name, type: orderType, - price: priceMethod === 'MARKET' ? 0 : stock.price, + price: priceMethod === 'MARKET' ? 0 : localStock.price, quantity: finalQuantity }); } @@ -230,12 +233,12 @@ const TradeModal: React.FC = ({ stock, type: initialType, onClo return (
setActiveStrategy(type)} - className={`p-4 rounded-2xl border-2 transition-all cursor-pointer flex items-center justify-between ${isSelected ? 'bg-amber-50/40 border-amber-300 shadow-sm' : 'bg-transparent border-slate-100 opacity-60'}`} + className={`p-3 rounded-2xl border-2 transition-all cursor-pointer flex items-center justify-between ${isSelected ? 'bg-amber-50/40 border-amber-300 shadow-sm' : 'bg-transparent border-slate-100 opacity-60'}`} > -
- {isSelected ? : } - - {label} +
+ {isSelected ? : } + + {label}
{isSelected && type === 'TRAILING_STOP' && ( @@ -268,10 +271,10 @@ const TradeModal: React.FC = ({ stock, type: initialType, onClo return (
-
+
{/* 헤더 */} -
+
-
-

= 0 ? 'text-rose-500' : 'text-blue-600'}`}> - {stock.market === MarketType.DOMESTIC ? stock.price.toLocaleString() : `$${stock.price}`} -

-
= 0 ? 'text-rose-500' : 'text-blue-600'}`}> - {stock.changePercent >= 0 ? : } - {Math.abs(stock.changePercent)}% + {localStock.name[0]} + +
setIsSearchOpen(true)}> +

+ {localStock.name} +

+
+ {localStock.market === MarketType.DOMESTIC ? 'KRX' : 'NYSE'} + {localStock.code}
-
-

거래량

-

{stock.volume.toLocaleString()}

-
- +5.2% -
-
-
-
-

사용가능 예수금

-

{buyingPower.toLocaleString()}{currencySymbol}

+ {/* 2. 시세 및 계좌 정보 (가로 나열) */} +
+ {/* 시세 */} +
+

= 0 ? 'text-rose-500' : 'text-blue-600'}`}> + {localStock.market === MarketType.DOMESTIC ? localStock.price.toLocaleString() : `$${localStock.price}`} +

+
= 0 ? 'text-rose-500' : 'text-blue-600'}`}> + {localStock.changePercent >= 0 ? : } + {Math.abs(localStock.changePercent)}% +
- {holding ? ( -
-
-

보유수량

-

{holding.quantity} EA

+ + {/* 거래량 */} +
+

거래량

+

{localStock.volume.toLocaleString()}

+
+ +5.2% +
+
+ + {/* 예수금 */} +
+

예수금

+

{buyingPower.toLocaleString()}{currencySymbol}

+
+ + {/* 보유/평가 */} +
+ {holding ? ( +
+
+ 보유 + {holding.quantity} EA +
+
+ 수익 + = 0 ? 'text-rose-500' : 'text-blue-600'}`}> + {plInfo!.percent.toFixed(1)}% + +
-
-

평가수익금

-

= 0 ? 'text-rose-500' : 'text-blue-600'}`}> - {plInfo!.pl > 0 ? '+' : ''}{plInfo!.pl.toLocaleString()} ({plInfo!.percent.toFixed(2)}%) -

+ ) : ( +
+

미보유

-
- ) : ( -
-

미보유 종목

-
- )} + )} +
-
+
-

- +

+ 1. 자동 감시 전략

-
-

- +
+

+ 2. 매매 실행 조건

-
+
@@ -520,7 +544,7 @@ const TradeModal: React.FC = ({ stock, type: initialType, onClo
-
+

{summaryMessage}

@@ -531,7 +555,7 @@ const TradeModal: React.FC = ({ stock, type: initialType, onClo
{/* 푸터 */} -
+
{monitoringEnabled && (
@@ -554,6 +578,21 @@ const TradeModal: React.FC = ({ stock, type: initialType, onClo {monitoringEnabled ? <> 전략 저장 및 {immediateStart ? '즉시 활성화' : '대기 저장'} : <> {isBuyMode ? '매수' : '매도'} 주문 즉시 전송}
+ + {isSearchOpen && ( + setIsSearchOpen(false)} + onSelect={(newStock) => { + setLocalStock(newStock); + setIsSearchOpen(false); + // 초기화 로직 (트리거 등 새 종목 가격 연동) + setTriggerValue(newStock.price); + setProfitValue(newStock.price * 1.1); + setLossValue(newStock.price * 0.9); + }} + /> + )}
);