import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Autocomplete, TextField, CircularProgress, Box, SxProps, Theme, IconButton, Typography, Divider } from '@mui/material'; import { Star, StarBorder } from '@mui/icons-material'; import upbitApiService, { UpbitMarket, UpbitTicker } from '../services/upbitApi'; import { toggleFavorite, getAllFavorites } from '../services/cryptoFavoriteApi'; interface CryptoNameComboBoxProps { value: string; onChange: (symbol: string, cryptoInfo?: UpbitMarket) => void; disabled?: boolean; label?: string; placeholder?: string; helperText?: string; size?: 'small' | 'medium'; sx?: SxProps; } type SortKey = 'favorite' | 'korean_name' | 'trade_price' | 'signed_change_rate' | 'acc_trade_price_24h'; type SortOrder = 'asc' | 'desc'; const HEADER_ROW_HEIGHT = 30; const formatPrice = (price?: number): string => { if (price == null) return '-'; if (price >= 1000) return price.toLocaleString(); if (price >= 1) return price.toLocaleString(undefined, { maximumFractionDigits: 4 }); return price.toLocaleString(undefined, { maximumFractionDigits: 8 }); }; const formatChangeRate = (rate?: number): string => { if (rate == null) return '-'; const percent = rate * 100; const sign = percent > 0 ? '+' : ''; return `${sign}${percent.toFixed(2)}%`; }; const formatTradeValue = (value?: number): string => { if (value == null) return '-'; if (value >= 1_0000_0000_0000) return `${(value / 1_0000_0000_0000).toFixed(2)}조`; if (value >= 1_0000_0000) return `${(value / 1_0000_0000).toFixed(1)}억`; if (value >= 1_0000) return `${(value / 1_0000).toFixed(1)}만`; return value.toLocaleString(); }; const compareNumber = (a?: number, b?: number): number => { const av = a ?? Number.NEGATIVE_INFINITY; const bv = b ?? Number.NEGATIVE_INFINITY; return av - bv; }; const compareString = (a?: string, b?: string): number => (a ?? '').localeCompare(b ?? '', 'ko'); const CryptoNameComboBox: React.FC = ({ value, onChange, disabled = false, label = '암호화폐명', placeholder = '암호화폐명을 입력하거나 선택하세요', helperText, size = 'medium', sx, }) => { const [cryptos, setCryptos] = useState([]); const [loading, setLoading] = useState(false); const [inputValue, setInputValue] = useState(''); const [selectedCrypto, setSelectedCrypto] = useState(null); const [isOpen, setIsOpen] = useState(false); const [isUserTyping, setIsUserTyping] = useState(false); const [favorites, setFavorites] = useState>({}); const [tickerMap, setTickerMap] = useState>({}); const [sortKey, setSortKey] = useState('favorite'); const [sortOrder, setSortOrder] = useState('desc'); const [dropdownWidth, setDropdownWidth] = useState(520); const inputRef = useRef(null); const comboRootRef = useRef(null); const fetchTickers = useCallback(async (markets: string[]) => { if (markets.length === 0) return; const chunkSize = 80; const chunks: string[][] = []; for (let i = 0; i < markets.length; i += chunkSize) { chunks.push(markets.slice(i, i + chunkSize)); } try { const results = await Promise.all(chunks.map((chunk) => upbitApiService.getCurrentPrice(chunk))); const next: Record = {}; results.flat().forEach((ticker) => { if (ticker?.market) next[ticker.market] = ticker; }); setTickerMap(next); } catch (error) { console.error('티커 로드 실패:', error); } }, []); // 컴포넌트 마운트 시 암호화폐 목록 로드 useEffect(() => { const loadAllCryptos = async () => { try { setLoading(true); const activeCryptos = await upbitApiService.getActiveCryptos(); const convertedCryptos = activeCryptos.map(crypto => ({ market: crypto.symbol, korean_name: crypto.koreanName, english_name: crypto.englishName })); setCryptos(convertedCryptos); await fetchTickers(convertedCryptos.map((c) => c.market)); // 즐겨찾기 목록 한 번에 로드 try { const favList = await getAllFavorites(); const favMap: Record = {}; favList.forEach(fav => { favMap[fav.symbol] = true; }); setFavorites(favMap); } catch (error) { console.error('즐겨찾기 목록 로드 실패:', error); setFavorites({}); } } catch (error) { console.error('암호화폐 목록 로드 실패:', error); const defaultCryptos = [ { market: 'KRW-BTC', korean_name: '비트코인', english_name: 'Bitcoin' }, { market: 'KRW-ETH', korean_name: '이더리움', english_name: 'Ethereum' }, { market: 'KRW-XRP', korean_name: '리플', english_name: 'Ripple' }, { market: 'KRW-ADA', korean_name: '에이다', english_name: 'Cardano' }, { market: 'KRW-SOL', korean_name: '솔라나', english_name: 'Solana' }, ]; setCryptos(defaultCryptos); } finally { setLoading(false); } }; loadAllCryptos(); }, [fetchTickers]); useEffect(() => { if (!isOpen || cryptos.length === 0) return; void fetchTickers(cryptos.map((c) => c.market)); }, [isOpen, cryptos, fetchTickers]); // 부모에서 종목이 바뀌면(알림·FAB 등) 검색 중 플래그 해제 → 아래 동기화 effect가 한글명 반영 useEffect(() => { setIsUserTyping(false); }, [value]); // 외부 value 변경 시 선택 상태 동기화 useEffect(() => { // 사용자가 타이핑 중이거나 드롭다운이 열려있을 때는 동기화하지 않음 if (isUserTyping || isOpen) { return; } if (value && cryptos.length > 0) { const crypto = cryptos.find(c => c.market === value); if (crypto && crypto !== selectedCrypto) { setSelectedCrypto(crypto); setInputValue(crypto.korean_name); } } else if (!value) { setSelectedCrypto(null); setInputValue(''); } }, [value, cryptos, selectedCrypto, isUserTyping, isOpen]); // 필터링된 종목 목록 // - 드롭다운이 열릴 때 (isOpen=true) 사용자가 타이핑 중일 때만 필터링 // - 목록을 펼칠 때는 모든 종목 표시 // - 즐겨찾기를 먼저 표시하도록 정렬 const getFilteredOptions = useCallback(() => { let filteredList: UpbitMarket[]; // 사용자가 타이핑 중일 때만 필터링 if (isUserTyping && inputValue) { const lowerCaseInput = inputValue.toLowerCase(); filteredList = cryptos.filter(crypto => { if (!crypto || !crypto.korean_name || !crypto.english_name || !crypto.market) return false; return crypto.korean_name.toLowerCase().includes(lowerCaseInput) || crypto.english_name.toLowerCase().includes(lowerCaseInput) || crypto.market.toLowerCase().includes(lowerCaseInput); }); } else { // 목록을 펼칠 때는 모든 종목 표시 filteredList = cryptos; } return [...filteredList].sort((a, b) => { const tA = tickerMap[a.market]; const tB = tickerMap[b.market]; let cmp = 0; if (sortKey === 'favorite') { cmp = Number(!!favorites[a.market]) - Number(!!favorites[b.market]); } else if (sortKey === 'korean_name') { cmp = compareString(a.korean_name, b.korean_name); } else if (sortKey === 'trade_price') { cmp = compareNumber(tA?.trade_price, tB?.trade_price); } else if (sortKey === 'signed_change_rate') { cmp = compareNumber(tA?.signed_change_rate, tB?.signed_change_rate); } else if (sortKey === 'acc_trade_price_24h') { cmp = compareNumber(tA?.acc_trade_price_24h, tB?.acc_trade_price_24h); } if (cmp === 0) { cmp = compareString(a.korean_name, b.korean_name); } return sortOrder === 'asc' ? cmp : -cmp; }); }, [cryptos, inputValue, isUserTyping, favorites, tickerMap, sortKey, sortOrder]); const handleSort = useCallback((key: SortKey) => { if (sortKey === key) { setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc')); return; } setSortKey(key); setSortOrder(key === 'korean_name' ? 'asc' : 'desc'); }, [sortKey]); // 드롭다운 열림/닫힘 핸들러 const handleOpen = () => { const inputWidth = comboRootRef.current?.clientWidth ?? 0; // 입력란 좌측 기준으로 붙이고, 너무 좁지 않게 최소 폭 보장 setDropdownWidth(Math.max(520, inputWidth)); setIsOpen(true); setIsUserTyping(false); // 목록을 펼칠 때는 필터링 비활성화 }; const handleClose = () => { setIsOpen(false); setIsUserTyping(false); // 닫힐 때 입력값이 비어있거나 매칭되는 종목이 없으면 선택된 종목으로 복원 if (selectedCrypto && !inputValue) { setInputValue(selectedCrypto.korean_name); } else if (!selectedCrypto && !inputValue) { // 선택된 종목도 없고 입력값도 없으면 그대로 유지 setInputValue(''); } }; // 종목 선택 핸들러 — 목록에서 고를 때만 부모 onChange(차트 갱신). Clear는 검색칸만 비움. const handleChange = (event: React.SyntheticEvent, newValue: UpbitMarket | null) => { if (newValue) { setSelectedCrypto(newValue); setIsUserTyping(false); setInputValue(newValue.korean_name); onChange(newValue.market, newValue); return; } // Clear(X): 실제 선택·부모 symbol·차트는 유지, 입력만 비워 다음 종목 검색 가능 const keep = (value && cryptos.length > 0 ? cryptos.find((c) => c.market === value) : null) ?? selectedCrypto; if (keep) { setSelectedCrypto(keep); setInputValue(''); setIsUserTyping(true); } else { setSelectedCrypto(null); setInputValue(''); setIsUserTyping(false); } }; // 입력 변경 핸들러 const handleInputChange = (event: React.SyntheticEvent, newInputValue: string, reason: string) => { setInputValue(newInputValue); // 사용자가 키보드로 직접 입력하거나 clear할 때 필터링 활성화 if (reason === 'input' || reason === 'clear') { setIsUserTyping(true); } else if (reason === 'reset') { // 선택이 완료되었을 때만 타이핑 상태 해제 setIsUserTyping(false); } }; // 키 입력 핸들러 - 엔터키 처리 const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter' && isUserTyping) { const filteredOptions = getFilteredOptions(); // 필터링된 결과가 하나이면 해당 종목 선택 if (filteredOptions.length === 1) { const crypto = filteredOptions[0]; setSelectedCrypto(crypto); setInputValue(crypto.korean_name); setIsUserTyping(false); setIsOpen(false); onChange(crypto.market, crypto); } else if (filteredOptions.length > 1) { // 필터링된 결과 중 정확히 일치하는 종목이 있으면 선택 const exactMatch = filteredOptions.find(c => c.korean_name.toLowerCase() === inputValue.toLowerCase() || c.english_name.toLowerCase() === inputValue.toLowerCase() || c.market.toLowerCase() === inputValue.toLowerCase() ); if (exactMatch) { setSelectedCrypto(exactMatch); setInputValue(exactMatch.korean_name); setIsUserTyping(false); setIsOpen(false); onChange(exactMatch.market, exactMatch); } } } }; // 즐겨찾기 토글 핸들러 const handleToggleFavorite = async (e: React.MouseEvent, option: UpbitMarket) => { e.stopPropagation(); e.preventDefault(); try { const result = await toggleFavorite({ symbol: option.market, koreanName: option.korean_name, englishName: option.english_name, }); setFavorites(prev => ({ ...prev, [option.market]: result })); } catch (error) { console.error('즐겨찾기 토글 실패:', error); } }; return ( option.korean_name} isOptionEqualToValue={(option, val) => option.market === val.market} loading={loading} disabled={disabled} value={selectedCrypto} open={isOpen} onOpen={handleOpen} onClose={handleClose} onChange={handleChange} inputValue={inputValue} onInputChange={handleInputChange} filterOptions={(options) => options} // 커스텀 필터링 사용 size={size} freeSolo={false} // 목록에서만 선택 가능 clearOnBlur={false} // 포커스를 잃어도 입력값 유지 selectOnFocus={false} // 포커스 시 자동 선택 안함 handleHomeEndKeys={false} disableClearable={false} // X 버튼 표시 sx={sx} renderInput={(params) => ( {loading ? : null} {params.InputProps.endAdornment} ), }} /> )} renderOption={(props, option, state) => { const isFav = favorites[option.market] || false; const ticker = tickerMap[option.market]; const options = getFilteredOptions(); const currentIndex = state.index; const isFirstRow = currentIndex === 0; // 현재 항목이 즐겨찾기이고, 다음 항목이 일반 항목인지 확인 const nextOption = options[currentIndex + 1]; const isLastFavorite = isFav && nextOption && !favorites[nextOption.market]; return ( {isFirstRow && ( { e.preventDefault(); e.stopPropagation(); }} onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleSort('favorite'); }} > 즐겨 { e.preventDefault(); e.stopPropagation(); }} onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleSort('korean_name'); }} > 한글명 {sortKey === 'korean_name' ? (sortOrder === 'asc' ? '▲' : '▼') : ''} { e.preventDefault(); e.stopPropagation(); }} onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleSort('trade_price'); }} > 현재가 {sortKey === 'trade_price' ? (sortOrder === 'asc' ? '▲' : '▼') : ''} { e.preventDefault(); e.stopPropagation(); }} onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleSort('signed_change_rate'); }} > 전일대비 {sortKey === 'signed_change_rate' ? (sortOrder === 'asc' ? '▲' : '▼') : ''} { e.preventDefault(); e.stopPropagation(); }} onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleSort('acc_trade_price_24h'); }} > 거래대금 {sortKey === 'acc_trade_price_24h' ? (sortOrder === 'asc' ? '▲' : '▼') : ''} )} handleToggleFavorite(e, option)} sx={{ color: isFav ? '#fbbf24' : 'action.disabled', padding: '2px', }} > {isFav ? : } {/* 한글명 */} {option.korean_name} {option.market.replace('KRW-', '')} {/* 현재가 */} {formatPrice(ticker?.trade_price)} {/* 전일대비 */} 0 ? 'error.main' : (ticker?.signed_change_rate ?? 0) < 0 ? 'info.main' : 'text.secondary', }} > {formatChangeRate(ticker?.signed_change_rate)} {/* 거래대금 */} {formatTradeValue(ticker?.acc_trade_price_24h)} {/* 즐겨찾기와 일반 종목 사이 divider */} {isLastFavorite && ( )} ); }} ListboxProps={{ sx: { width: '100%', maxHeight: '70vh', pt: 0, }, }} componentsProps={{ popper: { style: { width: `${dropdownWidth}px`, }, placement: 'bottom-start', }, paper: { sx: { maxHeight: '72vh', }, }, }} /> ); }; export default CryptoNameComboBox;