goldenChat base source add
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 차트 칸 타이틀 드래그 시 DragOverlay에 표시하는 미리보기 카드
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { ShowChart as ShowChartIcon, DragIndicator as DragIndicatorIcon } from '@mui/icons-material';
|
||||
import type { StockInfo } from '../../stores/useDashboardStore';
|
||||
|
||||
export interface ChartDragOverlayPreviewProps {
|
||||
stock: StockInfo;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const ChartDragOverlayPreview: React.FC<ChartDragOverlayPreviewProps> = ({ stock, width, height }) => {
|
||||
const maxW = typeof window !== 'undefined' ? Math.min(width, window.innerWidth - 24) : width;
|
||||
const maxH = typeof window !== 'undefined' ? Math.min(height, Math.round(window.innerHeight * 0.5)) : height;
|
||||
const w = Math.max(200, maxW);
|
||||
const h = Math.max(160, maxH);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: w,
|
||||
height: h,
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
boxShadow: 12,
|
||||
border: '2px solid',
|
||||
borderColor: 'primary.main',
|
||||
bgcolor: 'background.paper',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
pointerEvents: 'none',
|
||||
cursor: 'grabbing',
|
||||
opacity: 0.97,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
flexShrink: 0,
|
||||
bgcolor: 'action.selected',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<DragIndicatorIcon sx={{ fontSize: 16, color: 'text.secondary', flexShrink: 0 }} />
|
||||
<Typography variant="caption" fontWeight="bold" noWrap sx={{ flex: 1, minWidth: 0 }}>
|
||||
{stock.koreanName}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>
|
||||
{stock.symbol}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(180deg, #2a2e39 0%, #131722 55%, #0c0e12 100%)',
|
||||
}}
|
||||
>
|
||||
<ShowChartIcon sx={{ fontSize: Math.min(72, h * 0.22), color: 'grey.500', opacity: 0.45 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 멀티차트 WebSocket 연결 상태 표시
|
||||
* - 클릭 시 연결 목록 및 수신 상태 팝업
|
||||
* - MultiChartMarketDataProvider 내부에서 사용
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Box, Popover, LinearProgress, Typography, Tooltip, Button } from '@mui/material';
|
||||
import { useMultiChartMarketData } from '../../contexts/MultiChartMarketDataContext';
|
||||
import type { ConnectionStatus } from '../../hooks/useWebSocketConnectionStatus';
|
||||
|
||||
const WS_STATUS_CONFIG: Record<ConnectionStatus, { label: string; color: string; bgColor: string }> = {
|
||||
connected: { label: '실시간 연결됨', color: 'success.main', bgColor: 'success.main' },
|
||||
connecting: { label: '연결 중...', color: 'warning.main', bgColor: 'warning.main' },
|
||||
disconnected: { label: '연결 끊김', color: 'text.secondary', bgColor: 'grey.400' },
|
||||
error: { label: '연결 오류', color: 'error.main', bgColor: 'error.main' },
|
||||
};
|
||||
|
||||
export const MultiChartWebSocketStatus: React.FC = () => {
|
||||
const ctx = useMultiChartMarketData();
|
||||
const status = ctx?.status ?? 'disconnected';
|
||||
const connectionList = ctx?.connectionList ?? [];
|
||||
const [popoverAnchor, setPopoverAnchor] = React.useState<HTMLElement | null>(null);
|
||||
const [, setProgressTick] = React.useState(0);
|
||||
const [dataReceivedBlink, setDataReceivedBlink] = React.useState(false); // ✅ 데이터 수신 깜빡임
|
||||
const lastReceivedTimestampRef = React.useRef<number>(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!popoverAnchor) return;
|
||||
const id = setInterval(() => setProgressTick((t) => t + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [popoverAnchor]);
|
||||
|
||||
// ✅ connectionList의 lastReceivedAt 변화 감지 → 깜빡임 트리거
|
||||
React.useEffect(() => {
|
||||
if (connectionList.length === 0) return;
|
||||
|
||||
const latestReceived = Math.max(...connectionList.map(c => c.lastReceivedAt));
|
||||
|
||||
if (latestReceived > 0 && latestReceived !== lastReceivedTimestampRef.current) {
|
||||
lastReceivedTimestampRef.current = latestReceived;
|
||||
setDataReceivedBlink(true);
|
||||
setTimeout(() => setDataReceivedBlink(false), 300); // 300ms 후 자동 꺼짐
|
||||
}
|
||||
}, [connectionList]);
|
||||
|
||||
const config = WS_STATUS_CONFIG[status as ConnectionStatus] ?? WS_STATUS_CONFIG.disconnected;
|
||||
|
||||
const getReceiveProgress = (lastReceivedAt: number) => {
|
||||
if (!lastReceivedAt) return 0;
|
||||
const elapsed = (Date.now() - lastReceivedAt) / 1000;
|
||||
if (elapsed <= 5) return 100;
|
||||
if (elapsed >= 30) return 0;
|
||||
return Math.round(100 - ((elapsed - 5) / 25) * 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={`WebSocket: ${config.label} (클릭하여 연결 목록 보기)`}>
|
||||
<Box
|
||||
onClick={(e) => setPopoverAnchor(e.currentTarget)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'action.hover',
|
||||
fontSize: '0.75rem',
|
||||
color: config.color,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { bgcolor: 'action.selected' },
|
||||
// ✅ 데이터 수신 시 연두색 형광 깜빡임
|
||||
...(dataReceivedBlink && status === 'connected' ? {
|
||||
boxShadow: '0 0 10px 3px rgba(204, 255, 0, 0.7), inset 0 0 8px 2px rgba(204, 255, 0, 0.3)',
|
||||
transition: 'box-shadow 0.15s ease-out',
|
||||
} : {}),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
bgcolor: config.bgColor,
|
||||
...(status === 'connecting' && {
|
||||
animation: 'pulse 1.5s ease-in-out infinite',
|
||||
'@keyframes pulse': { '0%, 100%': { opacity: 1 }, '50%': { opacity: 0.4 } },
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
{config.label}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Popover
|
||||
open={Boolean(popoverAnchor)}
|
||||
anchorEl={popoverAnchor}
|
||||
onClose={() => setPopoverAnchor(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
slotProps={{ paper: { sx: { mt: 0.5, minWidth: 280 } } }}
|
||||
>
|
||||
<Box sx={{ p: 1.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
WebSocket 연결 목록
|
||||
</Typography>
|
||||
{connectionList.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
구독 중인 차트가 없습니다.
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{connectionList.map((item) => {
|
||||
const progress = getReceiveProgress(item.lastReceivedAt);
|
||||
const hasNoData = item.candleCount === 0;
|
||||
const displayName = item.koreanName || item.market;
|
||||
return (
|
||||
<Box key={`${item.market}:${item.timeframe}`} sx={{ minWidth: 240 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.25 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{displayName}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.timeframe} · 캔들 {item.candleCount}개
|
||||
</Typography>
|
||||
</Box>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{
|
||||
height: 6,
|
||||
borderRadius: 1,
|
||||
bgcolor: 'action.hover',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
bgcolor: progress >= 80 ? 'success.main' : progress >= 40 ? 'warning.main' : 'grey.400',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{hasNoData && (
|
||||
<Typography variant="caption" color="error.main" sx={{ display: 'block', mt: 0.5 }}>
|
||||
데이터 미수신: Redis 수집 대기, 거래량 부족, 또는 해당 봉 데이터 미생성 가능
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1.5, pt: 1, borderTop: 1, borderColor: 'divider' }}>
|
||||
※ 캔들 0개: market-data 서비스에서 해당 종목 수집 중이거나, 해당 타임프레임 데이터가 아직 생성되지 않았을 수 있습니다.
|
||||
</Typography>
|
||||
{(status === 'disconnected' || status === 'error') && ctx?.reconnect && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
sx={{ mt: 1 }}
|
||||
onClick={() => {
|
||||
ctx.reconnect();
|
||||
setPopoverAnchor(null);
|
||||
}}
|
||||
>
|
||||
재연결
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 종목 선택 다이얼로그
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
} from '@mui/material';
|
||||
import { Search as SearchIcon } from '@mui/icons-material';
|
||||
import { MARKET_LIST } from '../../constants/marketList';
|
||||
import type { StockInfo } from '../../stores/useDashboardStore';
|
||||
|
||||
interface StockSelectDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (stock: StockInfo) => void;
|
||||
}
|
||||
|
||||
export const StockSelectDialog: React.FC<StockSelectDialogProps> = ({ open, onClose, onSelect }) => {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = MARKET_LIST.filter(
|
||||
(m) =>
|
||||
m.koreanName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
m.symbol.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const handleSelect = (item: (typeof MARKET_LIST)[0]) => {
|
||||
onSelect({
|
||||
symbol: item.symbol,
|
||||
koreanName: item.koreanName,
|
||||
englishName: item.englishName,
|
||||
});
|
||||
onClose();
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>종목 추가</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="검색..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
fullWidth
|
||||
sx={{ mb: 1 }}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<List sx={{ maxHeight: 320, overflow: 'auto' }}>
|
||||
{filtered.map((item) => (
|
||||
<ListItemButton key={item.symbol} onClick={() => handleSelect(item)}>
|
||||
<ListItemText
|
||||
primary={item.koreanName}
|
||||
secondary={item.symbol}
|
||||
/>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 멀티차트 대시보드 - 종목 선택 드롭다운 (펼친 상태에서 드래그 가능)
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
Box,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { Add as AddIcon, Search as SearchIcon, DragIndicator } from '@mui/icons-material';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { MARKET_LIST, type MarketItem } from '../../constants/marketList';
|
||||
import type { StockInfo } from '../../stores/useDashboardStore';
|
||||
|
||||
interface DraggableStockItemProps {
|
||||
item: MarketItem;
|
||||
onClick?: (item: MarketItem) => void;
|
||||
}
|
||||
|
||||
const DraggableStockItem: React.FC<DraggableStockItemProps> = ({ item, onClick }) => {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: `stock-${item.symbol}`,
|
||||
data: {
|
||||
type: 'stock',
|
||||
stock: {
|
||||
symbol: item.symbol,
|
||||
koreanName: item.koreanName,
|
||||
englishName: item.englishName,
|
||||
} as StockInfo,
|
||||
},
|
||||
});
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// 드래그 중이 아닐 때만 클릭 이벤트 처리
|
||||
if (!isDragging && onClick) {
|
||||
e.stopPropagation();
|
||||
onClick(item);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItem disablePadding ref={setNodeRef} {...attributes} {...listeners}>
|
||||
<ListItemButton
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
py: 0.75,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
borderRadius: 1,
|
||||
'&:hover': { bgcolor: 'action.hover' },
|
||||
}}
|
||||
>
|
||||
<DragIndicator fontSize="small" sx={{ mr: 1, color: 'text.disabled' }} />
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" noWrap>
|
||||
{item.koreanName}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.symbol}
|
||||
</Typography>
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
interface StockSelectDropdownProps {
|
||||
open: boolean;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
/** 종목 선택 시 콜백 (클릭으로 선택) */
|
||||
onSelect?: (stock: StockInfo) => void;
|
||||
/** 드래그 중에는 메뉴 닫지 않음 */
|
||||
isDraggingFromMenu?: boolean;
|
||||
}
|
||||
|
||||
export const StockSelectDropdown: React.FC<StockSelectDropdownProps> = ({
|
||||
open,
|
||||
onOpen,
|
||||
onClose,
|
||||
onSelect,
|
||||
isDraggingFromMenu = false,
|
||||
}) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const handleOpen = (e: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(e.currentTarget);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const menuOpen = open && Boolean(anchorEl);
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isDraggingFromMenu) {
|
||||
setAnchorEl(null);
|
||||
setSearch('');
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemClick = (item: MarketItem) => {
|
||||
if (onSelect) {
|
||||
onSelect({
|
||||
symbol: item.symbol,
|
||||
koreanName: item.koreanName,
|
||||
englishName: item.englishName,
|
||||
});
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setAnchorEl(null);
|
||||
setSearch('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const filtered = MARKET_LIST.filter(
|
||||
(m) =>
|
||||
m.koreanName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
m.symbol.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleOpen}
|
||||
sx={{ minWidth: 140, justifyContent: 'flex-start' }}
|
||||
>
|
||||
종목 선택
|
||||
</Button>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={menuOpen}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
maxHeight: 400,
|
||||
width: 280,
|
||||
mt: 1.5,
|
||||
},
|
||||
}}
|
||||
disableAutoFocusItem
|
||||
disableScrollLock
|
||||
>
|
||||
<Box sx={{ px: 1.5, pt: 1, pb: 0.5 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="검색..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
fullWidth
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{ '& .MuiOutlinedInput-root': { bgcolor: 'action.hover' } }}
|
||||
/>
|
||||
</Box>
|
||||
<List sx={{ maxHeight: 320, overflow: 'auto', py: 0 }}>
|
||||
{filtered.map((item) => (
|
||||
<DraggableStockItem key={item.symbol} item={item} onClick={handleItemClick} />
|
||||
))}
|
||||
</List>
|
||||
{filtered.length === 0 && (
|
||||
<Box sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
검색 결과 없음
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 멀티차트 대시보드 - 드래그 가능한 종목 리스트 사이드바
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import { Search as SearchIcon, DragIndicator } from '@mui/icons-material';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { MARKET_LIST, type MarketItem } from '../../constants/marketList';
|
||||
import type { StockInfo } from '../../stores/useDashboardStore';
|
||||
|
||||
interface DraggableStockProps {
|
||||
item: MarketItem;
|
||||
}
|
||||
|
||||
const DraggableStock: React.FC<DraggableStockProps> = ({ item }) => {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: `stock-${item.symbol}`,
|
||||
data: {
|
||||
type: 'stock',
|
||||
stock: {
|
||||
symbol: item.symbol,
|
||||
koreanName: item.koreanName,
|
||||
englishName: item.englishName,
|
||||
} as StockInfo,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ListItem disablePadding ref={setNodeRef} {...attributes} {...listeners}>
|
||||
<ListItemButton
|
||||
sx={{
|
||||
py: 0.75,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
borderRadius: 1,
|
||||
'&:hover': { bgcolor: 'action.hover' },
|
||||
}}
|
||||
>
|
||||
<DragIndicator fontSize="small" sx={{ mr: 1, color: 'text.disabled' }} />
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography variant="body2" noWrap>
|
||||
{item.koreanName}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.symbol}
|
||||
</Typography>
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
interface StockSidebarProps {
|
||||
onSelectStock?: (stock: StockInfo) => void;
|
||||
}
|
||||
|
||||
export const StockSidebar: React.FC<StockSidebarProps> = ({ onSelectStock }) => {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = MARKET_LIST.filter(
|
||||
(m) =>
|
||||
m.koreanName.toLowerCase().includes(search.toLowerCase()) ||
|
||||
m.symbol.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
width: 240,
|
||||
flexShrink: 0,
|
||||
bgcolor: 'background.paper',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 1.5, borderBottom: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography variant="subtitle2" fontWeight="bold" gutterBottom>
|
||||
종목 목록
|
||||
</Typography>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="검색..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
fullWidth
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{ '& .MuiOutlinedInput-root': { bgcolor: 'action.hover' } }}
|
||||
/>
|
||||
</Box>
|
||||
<List sx={{ maxHeight: 400, overflow: 'auto', py: 0 }}>
|
||||
{filtered.map((item) => (
|
||||
<DraggableStock key={item.symbol} item={item} />
|
||||
))}
|
||||
</List>
|
||||
{filtered.length === 0 && (
|
||||
<Box sx={{ p: 2, textAlign: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
검색 결과 없음
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user