가상투자 메뉴 기능 구현
This commit is contained in:
@@ -42,16 +42,35 @@ interface MarketSearchPanelProps {
|
||||
* 없으면 기본(전체화면 좌측 고정) 위치로 표시된다.
|
||||
*/
|
||||
anchorRect?: DOMRect | null;
|
||||
/** overlay=팝업/고정패널, embedded=좌측 패널 인라인 목록 */
|
||||
variant?: 'overlay' | 'embedded';
|
||||
/** 외부 입력과 검색어 동기화 */
|
||||
initialQuery?: string;
|
||||
/** 제어형 검색어 (embedded 등 외부 input 사용 시) */
|
||||
query?: string;
|
||||
onQueryChange?: (query: string) => void;
|
||||
/** favorite=별표, add=투자대상 추가(+) */
|
||||
actionMode?: 'favorite' | 'add';
|
||||
/** actionMode=add 일 때 + 클릭 콜백 (행 선택과 분리) */
|
||||
onAdd?: (market: string, meta: { koreanName: string; englishName: string }) => void;
|
||||
/** 이미 추가된 종목 (add 모드에서 + 비활성) */
|
||||
addedMarkets?: Set<string>;
|
||||
}
|
||||
|
||||
export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||
currentMarket, onSelect, onClose, anchorRect, initialQuery = '',
|
||||
variant = 'overlay', query: controlledQuery, onQueryChange,
|
||||
actionMode = 'favorite', onAdd, addedMarkets,
|
||||
}) => {
|
||||
const embedded = variant === 'embedded';
|
||||
const [markets, setMarkets] = useState<MarketInfo[]>([]);
|
||||
const [tickers, setTickers] = useState<Record<string, TickerInfo>>({});
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [internalQuery, setInternalQuery] = useState(initialQuery);
|
||||
const query = controlledQuery ?? internalQuery;
|
||||
const setQuery = useCallback((next: string) => {
|
||||
if (controlledQuery === undefined) setInternalQuery(next);
|
||||
onQueryChange?.(next);
|
||||
}, [controlledQuery, onQueryChange]);
|
||||
const [favs, setFavs] = useState<Set<string>>(() => new Set(getFavorites()));
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -84,13 +103,13 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
if (!embedded) setTimeout(() => inputRef.current?.focus(), 50);
|
||||
refreshFavs();
|
||||
}, [refreshFavs]);
|
||||
}, [refreshFavs, embedded]);
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(initialQuery);
|
||||
}, [initialQuery]);
|
||||
if (controlledQuery === undefined) setInternalQuery(initialQuery);
|
||||
}, [initialQuery, controlledQuery]);
|
||||
|
||||
// 좌측 마켓 패널 등에서 관심종목 변경 시 별표 동기화
|
||||
useEffect(() => {
|
||||
@@ -145,6 +164,30 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||
}
|
||||
}, [markets]);
|
||||
|
||||
const tryAddMarket = useCallback((market: string) => {
|
||||
if (actionMode === 'add') {
|
||||
onSelect(market);
|
||||
if (addedMarkets?.has(market)) {
|
||||
if (embedded) onClose();
|
||||
return;
|
||||
}
|
||||
const row = markets.find(m => m.market === market);
|
||||
onAdd?.(market, {
|
||||
koreanName: row?.korean_name ?? market,
|
||||
englishName: row?.english_name ?? market,
|
||||
});
|
||||
if (embedded) onClose();
|
||||
return;
|
||||
}
|
||||
onSelect(market);
|
||||
if (!embedded) onClose();
|
||||
}, [actionMode, onSelect, onAdd, addedMarkets, markets, embedded, onClose]);
|
||||
|
||||
const handleAddClick = useCallback((market: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
tryAddMarket(market);
|
||||
}, [tryAddMarket]);
|
||||
|
||||
// ── 4. 필터 + 정렬 ───────────────────────────────────────────────────────
|
||||
const q = query.trim();
|
||||
const filtered = markets.filter(m => {
|
||||
@@ -176,21 +219,23 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||
// 인라인 스타일로 z-index를 명시해 패널이 항상 최상단에 렌더되게 보장한다.
|
||||
const PANEL_Z = 99999;
|
||||
|
||||
const panelStyle: React.CSSProperties | undefined = anchorRect
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: anchorRect.top + HEADER_H,
|
||||
left: anchorRect.left,
|
||||
width: Math.max(anchorRect.width, MIN_W),
|
||||
height: anchorRect.height - HEADER_H,
|
||||
maxWidth: anchorRect.width,
|
||||
zIndex: PANEL_Z,
|
||||
// document.body 포털 시 CSS var()가 상속되지 않아 배경이 투명해지는 문제 방지
|
||||
background: 'var(--bg2, #24283b)',
|
||||
color: 'var(--text, #c0caf5)',
|
||||
border: '1px solid var(--border, rgba(122,162,247,0.15))',
|
||||
}
|
||||
: undefined; // undefined → CSS 기본값(.msp-panel) 그대로 사용
|
||||
const panelStyle: React.CSSProperties | undefined = embedded
|
||||
? undefined
|
||||
: anchorRect
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: anchorRect.top + HEADER_H,
|
||||
left: anchorRect.left,
|
||||
width: Math.max(anchorRect.width, MIN_W),
|
||||
height: anchorRect.height - HEADER_H,
|
||||
maxWidth: anchorRect.width,
|
||||
zIndex: PANEL_Z,
|
||||
// document.body 포털 시 CSS var()가 상속되지 않아 배경이 투명해지는 문제 방지
|
||||
background: 'var(--bg2, #24283b)',
|
||||
color: 'var(--text, #c0caf5)',
|
||||
border: '1px solid var(--border, rgba(122,162,247,0.15))',
|
||||
}
|
||||
: undefined; // undefined → CSS 기본값(.msp-panel) 그대로 사용
|
||||
|
||||
// 슬롯 전용 backdrop: 해당 슬롯 영역만 반투명 오버레이로 덮어 패널이 명확히 보이게 함
|
||||
const backdropStyle: React.CSSProperties | undefined = anchorRect
|
||||
@@ -206,22 +251,26 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const handleRowSelect = useCallback((market: string) => {
|
||||
tryAddMarket(market);
|
||||
}, [tryAddMarket]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 배경 오버레이 (클릭 시 닫기) */}
|
||||
<div
|
||||
className="msp-backdrop"
|
||||
style={backdropStyle}
|
||||
onClick={onClose}
|
||||
/>
|
||||
{!embedded && (
|
||||
<div
|
||||
className="msp-backdrop"
|
||||
style={backdropStyle}
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 패널: anchorRect 있으면 인라인 스타일로 위치·z-index override */}
|
||||
<div
|
||||
className={`msp-panel${anchorRect ? ' msp-panel--slot' : ''}`}
|
||||
className={`msp-panel${anchorRect ? ' msp-panel--slot' : ''}${embedded ? ' msp-panel--embedded' : ''}`}
|
||||
style={panelStyle}
|
||||
>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
{!embedded && (
|
||||
<div className="msp-search-row">
|
||||
<span className="msp-search-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
@@ -236,10 +285,7 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Enter' && sorted.length > 0) {
|
||||
onSelect(sorted[0].market);
|
||||
onClose();
|
||||
}
|
||||
if (e.key === 'Enter' && sorted.length > 0) handleRowSelect(sorted[0].market);
|
||||
}}
|
||||
/>
|
||||
{query && (
|
||||
@@ -255,14 +301,18 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 헤더 */}
|
||||
<div className="msp-header">
|
||||
<span className="msp-col msp-col-fav" />
|
||||
<div className={`msp-header${embedded ? ' msp-header--embedded' : ''}`}>
|
||||
<span className="msp-col msp-col-fav">{embedded ? '추가' : ''}</span>
|
||||
<span className="msp-col msp-col-name">한글명</span>
|
||||
<span className="msp-col msp-col-price">현재가</span>
|
||||
<span className="msp-col msp-col-change">전일대비</span>
|
||||
<span className="msp-col msp-col-vol">거래대금</span>
|
||||
{!embedded && (
|
||||
<>
|
||||
<span className="msp-col msp-col-price">현재가</span>
|
||||
<span className="msp-col msp-col-change">전일대비</span>
|
||||
<span className="msp-col msp-col-vol">거래대금</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 목록 */}
|
||||
@@ -276,6 +326,7 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||
{sorted.map(m => {
|
||||
const tk = tickers[m.market];
|
||||
const isFav = favs.has(m.market);
|
||||
const isAdded = addedMarkets?.has(m.market) ?? false;
|
||||
const isActive = m.market === currentMarket;
|
||||
const rate = tk ? tk.signed_change_rate * 100 : null;
|
||||
const isUp = rate !== null && rate >= 0;
|
||||
@@ -283,40 +334,57 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||
return (
|
||||
<div
|
||||
key={m.market}
|
||||
className={`msp-item${isActive ? ' active' : ''}`}
|
||||
onClick={() => { onSelect(m.market); onClose(); }}
|
||||
className={`msp-item${embedded ? ' msp-item--embedded' : ''}${isActive ? ' active' : ''}`}
|
||||
onClick={() => handleRowSelect(m.market)}
|
||||
>
|
||||
{/* 즐겨찾기 */}
|
||||
<span
|
||||
className={`msp-col msp-col-fav msp-star${isFav ? ' starred' : ''}`}
|
||||
onClick={e => toggleFav(m.market, e)}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
{isFav ? '★' : '☆'}
|
||||
</span>
|
||||
{actionMode === 'add' ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`msp-add-btn${isAdded ? ' msp-add-btn--done' : ''}`}
|
||||
onClick={e => handleAddClick(m.market, e)}
|
||||
disabled={isAdded}
|
||||
title={isAdded ? '이미 추가됨' : '투자대상에 추가'}
|
||||
>
|
||||
{isAdded ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className={`msp-col msp-col-fav msp-star${isFav ? ' starred' : ''}`}
|
||||
onClick={e => toggleFav(m.market, e)}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
{isFav ? '★' : '☆'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 이름 */}
|
||||
<span className="msp-col msp-col-name">
|
||||
<span className="msp-kor">{m.korean_name}</span>
|
||||
<span className="msp-sym">{m.symbol}</span>
|
||||
</span>
|
||||
|
||||
{/* 현재가 */}
|
||||
<span className="msp-col msp-col-price">
|
||||
{tk ? tk.trade_price.toLocaleString() : '-'}
|
||||
</span>
|
||||
|
||||
{/* 전일대비 */}
|
||||
<span className={`msp-col msp-col-change ${rate === null ? '' : isUp ? 'up' : 'down'}`}>
|
||||
{rate === null
|
||||
? '-'
|
||||
: `${isUp ? '+' : ''}${rate.toFixed(2)}%`}
|
||||
</span>
|
||||
|
||||
{/* 거래대금 */}
|
||||
<span className="msp-col msp-col-vol">
|
||||
{tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'}
|
||||
</span>
|
||||
{!embedded && (
|
||||
<>
|
||||
<span className="msp-col msp-col-price">
|
||||
{tk ? tk.trade_price.toLocaleString() : '-'}
|
||||
</span>
|
||||
<span className={`msp-col msp-col-change ${rate === null ? '' : isUp ? 'up' : 'down'}`}>
|
||||
{rate === null
|
||||
? '-'
|
||||
: `${isUp ? '+' : ''}${rate.toFixed(2)}%`}
|
||||
</span>
|
||||
<span className="msp-col msp-col-vol">
|
||||
{tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user