Files
goldenChart/frontend_golden/src/components/CryptoNameComboBox.tsx
T
2026-05-23 15:11:48 +09:00

537 lines
20 KiB
TypeScript

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<Theme>;
}
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<CryptoNameComboBoxProps> = ({
value,
onChange,
disabled = false,
label = '암호화폐명',
placeholder = '암호화폐명을 입력하거나 선택하세요',
helperText,
size = 'medium',
sx,
}) => {
const [cryptos, setCryptos] = useState<UpbitMarket[]>([]);
const [loading, setLoading] = useState(false);
const [inputValue, setInputValue] = useState('');
const [selectedCrypto, setSelectedCrypto] = useState<UpbitMarket | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [isUserTyping, setIsUserTyping] = useState(false);
const [favorites, setFavorites] = useState<Record<string, boolean>>({});
const [tickerMap, setTickerMap] = useState<Record<string, UpbitTicker>>({});
const [sortKey, setSortKey] = useState<SortKey>('favorite');
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
const [dropdownWidth, setDropdownWidth] = useState<number>(520);
const inputRef = useRef<HTMLInputElement>(null);
const comboRootRef = useRef<HTMLDivElement>(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<string, UpbitTicker> = {};
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<string, boolean> = {};
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 (
<Box ref={comboRootRef}>
<Autocomplete
options={getFilteredOptions()}
getOptionLabel={(option) => 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) => (
<TextField
{...params}
inputRef={inputRef}
label={label || undefined}
placeholder={placeholder}
helperText={helperText || undefined}
fullWidth
size={size}
onKeyDown={handleKeyDown}
InputProps={{
...params.InputProps,
endAdornment: (
<React.Fragment>
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</React.Fragment>
),
}}
/>
)}
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 (
<React.Fragment key={option.market}>
{isFirstRow && (
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 1,
display: 'grid',
gridTemplateColumns: '28px minmax(120px, 1.2fr) minmax(92px, 0.9fr) minmax(82px, 0.8fr) minmax(82px, 0.8fr)',
alignItems: 'center',
px: 1.5,
height: HEADER_ROW_HEIGHT,
bgcolor: 'background.paper',
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<Typography
variant="caption"
sx={{ textAlign: 'center', cursor: 'pointer', userSelect: 'none' }}
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleSort('favorite'); }}
>
즐겨
</Typography>
<Typography
variant="caption"
sx={{ cursor: 'pointer', userSelect: 'none' }}
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleSort('korean_name'); }}
>
한글명 {sortKey === 'korean_name' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</Typography>
<Typography
variant="caption"
sx={{ textAlign: 'right', cursor: 'pointer', userSelect: 'none' }}
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleSort('trade_price'); }}
>
현재가 {sortKey === 'trade_price' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</Typography>
<Typography
variant="caption"
sx={{ textAlign: 'right', cursor: 'pointer', userSelect: 'none' }}
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleSort('signed_change_rate'); }}
>
전일대비 {sortKey === 'signed_change_rate' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</Typography>
<Typography
variant="caption"
sx={{ textAlign: 'right', cursor: 'pointer', userSelect: 'none' }}
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleSort('acc_trade_price_24h'); }}
>
거래대금 {sortKey === 'acc_trade_price_24h' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</Typography>
</Box>
)}
<Box
component="li"
{...props}
sx={{
display: 'grid !important',
gridTemplateColumns: '28px minmax(120px, 1.2fr) minmax(92px, 0.9fr) minmax(82px, 0.8fr) minmax(82px, 0.8fr) !important',
alignItems: 'center !important',
gap: 1,
padding: '8px 12px !important',
}}
>
<IconButton
size="small"
onClick={(e) => handleToggleFavorite(e, option)}
sx={{
color: isFav ? '#fbbf24' : 'action.disabled',
padding: '2px',
}}
>
{isFav ? <Star fontSize="small" /> : <StarBorder fontSize="small" />}
</IconButton>
{/* 한글명 */}
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
<Typography sx={{ fontWeight: 'bold', fontSize: '14px' }}>
{option.korean_name}
</Typography>
<Typography sx={{ fontSize: '12px', color: 'text.secondary' }}>
{option.market.replace('KRW-', '')}
</Typography>
</Box>
{/* 현재가 */}
<Typography sx={{ textAlign: 'right', fontWeight: 600, fontSize: '13px' }}>
{formatPrice(ticker?.trade_price)}
</Typography>
{/* 전일대비 */}
<Typography
sx={{
textAlign: 'right',
fontWeight: 600,
fontSize: '13px',
color:
(ticker?.signed_change_rate ?? 0) > 0
? 'error.main'
: (ticker?.signed_change_rate ?? 0) < 0
? 'info.main'
: 'text.secondary',
}}
>
{formatChangeRate(ticker?.signed_change_rate)}
</Typography>
{/* 거래대금 */}
<Typography sx={{ textAlign: 'right', fontSize: '12px', color: 'text.secondary' }}>
{formatTradeValue(ticker?.acc_trade_price_24h)}
</Typography>
</Box>
{/* 즐겨찾기와 일반 종목 사이 divider */}
{isLastFavorite && (
<Divider sx={{ my: 0.5 }} />
)}
</React.Fragment>
);
}}
ListboxProps={{
sx: {
width: '100%',
maxHeight: '70vh',
pt: 0,
},
}}
componentsProps={{
popper: {
style: {
width: `${dropdownWidth}px`,
},
placement: 'bottom-start',
},
paper: {
sx: {
maxHeight: '72vh',
},
},
}}
/>
</Box>
);
};
export default CryptoNameComboBox;