227 lines
12 KiB
TypeScript
227 lines
12 KiB
TypeScript
|
|
import React, { useState, useMemo, useEffect } from 'react';
|
|
import { Star, Plus, Trash2, Search, X, FolderPlus, ArrowRight, LayoutGrid, Globe, Edit3, Save, GripVertical } from 'lucide-react';
|
|
import { StockItem, WatchlistGroup, MarketType } from '../types';
|
|
import { DbService } from '../services/dbService';
|
|
import { StockRow } from '../components/StockRow';
|
|
|
|
interface WatchlistManagementProps {
|
|
marketMode: MarketType;
|
|
stocks: StockItem[];
|
|
groups: WatchlistGroup[];
|
|
onRefresh: () => void;
|
|
}
|
|
|
|
const WatchlistManagement: React.FC<WatchlistManagementProps> = ({ marketMode, stocks, groups, onRefresh }) => {
|
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
|
const [showAddGroupModal, setShowAddGroupModal] = useState(false);
|
|
const [showRenameModal, setShowRenameModal] = useState(false);
|
|
const [editingGroup, setEditingGroup] = useState<WatchlistGroup | null>(null);
|
|
const [newGroupName, setNewGroupName] = useState('');
|
|
const [renameValue, setRenameValue] = useState('');
|
|
const [stockSearch, setStockSearch] = useState('');
|
|
|
|
const [draggedItemIndex, setDraggedItemIndex] = useState<number | null>(null);
|
|
|
|
const dbService = useMemo(() => new DbService(), []);
|
|
|
|
const filteredGroups = useMemo(() => {
|
|
return groups.filter(g => g.market === marketMode);
|
|
}, [groups, marketMode]);
|
|
|
|
useEffect(() => {
|
|
if (filteredGroups.length > 0) {
|
|
if (!selectedGroupId || !filteredGroups.find(g => g.id === selectedGroupId)) {
|
|
setSelectedGroupId(filteredGroups[0].id);
|
|
}
|
|
} else {
|
|
setSelectedGroupId(null);
|
|
}
|
|
}, [marketMode, filteredGroups, selectedGroupId]);
|
|
|
|
const selectedGroup = useMemo(() =>
|
|
filteredGroups.find(g => g.id === selectedGroupId),
|
|
[filteredGroups, selectedGroupId]
|
|
);
|
|
|
|
const filteredSearchStocks = useMemo(() => {
|
|
if (!stockSearch) return [];
|
|
return stocks.filter(s =>
|
|
s.market === marketMode &&
|
|
(s.name.includes(stockSearch) || s.code.toLowerCase().includes(stockSearch.toLowerCase())) &&
|
|
!(selectedGroup?.codes.includes(s.code))
|
|
).slice(0, 5);
|
|
}, [stocks, stockSearch, marketMode, selectedGroup]);
|
|
|
|
const handleAddGroup = async () => {
|
|
if (!newGroupName.trim()) return;
|
|
const newGroup: WatchlistGroup = {
|
|
id: 'grp_' + Date.now(),
|
|
name: newGroupName,
|
|
codes: [],
|
|
market: marketMode
|
|
};
|
|
await dbService.saveWatchlistGroup(newGroup);
|
|
setNewGroupName('');
|
|
setShowAddGroupModal(false);
|
|
onRefresh();
|
|
setSelectedGroupId(newGroup.id);
|
|
};
|
|
|
|
const handleRenameGroup = async () => {
|
|
if (!editingGroup || !renameValue.trim()) return;
|
|
const updated = { ...editingGroup, name: renameValue };
|
|
await dbService.updateWatchlistGroup(updated);
|
|
setEditingGroup(null);
|
|
setRenameValue('');
|
|
setShowRenameModal(false);
|
|
onRefresh();
|
|
};
|
|
|
|
const handleDeleteGroup = async (id: string) => {
|
|
if (!confirm('그룹을 삭제하시겠습니까?')) return;
|
|
await dbService.deleteWatchlistGroup(id);
|
|
onRefresh();
|
|
if (selectedGroupId === id) setSelectedGroupId(null);
|
|
};
|
|
|
|
const handleAddStockToGroup = async (code: string) => {
|
|
if (!selectedGroup) return;
|
|
const updated = { ...selectedGroup, codes: [...selectedGroup.codes, code] };
|
|
await dbService.updateWatchlistGroup(updated);
|
|
setStockSearch('');
|
|
onRefresh();
|
|
};
|
|
|
|
const handleRemoveStockFromGroup = async (code: string) => {
|
|
if (!selectedGroup) return;
|
|
const stock = stocks.find(s => s.code === code);
|
|
const stockName = stock ? stock.name : code;
|
|
if (!confirm(`'${stockName}' 종목을 그룹에서 삭제하시겠습니까?`)) return;
|
|
const updated = { ...selectedGroup, codes: selectedGroup.codes.filter(c => c !== code) };
|
|
await dbService.updateWatchlistGroup(updated);
|
|
onRefresh();
|
|
};
|
|
|
|
const onDragStart = (e: React.DragEvent, index: number) => {
|
|
setDraggedItemIndex(index);
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
};
|
|
|
|
const onDrop = async (e: React.DragEvent, targetIndex: number) => {
|
|
if (!selectedGroup || draggedItemIndex === null || draggedItemIndex === targetIndex) return;
|
|
const newCodes = [...selectedGroup.codes];
|
|
const [movedItem] = newCodes.splice(draggedItemIndex, 1);
|
|
newCodes.splice(targetIndex, 0, movedItem);
|
|
const updated = { ...selectedGroup, codes: newCodes };
|
|
await dbService.updateWatchlistGroup(updated);
|
|
setDraggedItemIndex(null);
|
|
onRefresh();
|
|
};
|
|
|
|
return (
|
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 animate-in fade-in duration-500 pb-20">
|
|
<div className="lg:col-span-1 bg-white p-5 rounded-2xl shadow-sm border border-slate-100 flex flex-col h-[650px]">
|
|
<div className="flex justify-between items-center mb-5">
|
|
<h3 className="text-[16px] font-black text-slate-800 flex items-center gap-2 uppercase tracking-tighter">
|
|
<Star size={20} className="text-blue-600" /> {marketMode === MarketType.DOMESTIC ? '국내' : '해외'} 관심그룹
|
|
</h3>
|
|
</div>
|
|
<button onClick={() => setShowAddGroupModal(true)} className="w-full py-3 bg-slate-900 text-white rounded-xl font-black text-[12px] uppercase tracking-widest flex items-center justify-center gap-2 mb-6 shadow-lg shadow-slate-200 hover:bg-slate-800 transition-all active:scale-95"><FolderPlus size={16} /> 새 그룹 생성</button>
|
|
<div className="flex-1 overflow-y-auto pr-1 space-y-2 scrollbar-hide">
|
|
{filteredGroups.map(group => (
|
|
<div key={group.id} onClick={() => setSelectedGroupId(group.id)} className={`p-4 rounded-xl border-2 transition-all cursor-pointer group flex justify-between items-center ${selectedGroupId === group.id ? 'border-blue-500 bg-blue-50/20' : 'border-transparent bg-slate-50/70 hover:bg-white hover:border-slate-200'}`}>
|
|
<div className="flex-1 min-w-0 pr-2">
|
|
<p className={`font-black text-[14px] truncate ${selectedGroupId === group.id ? 'text-blue-600' : 'text-slate-800'}`}>{group.name}</p>
|
|
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">{group.codes.length} 종목</p>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<button onClick={(e) => { e.stopPropagation(); setEditingGroup(group); setRenameValue(group.name); setShowRenameModal(true); }} className="p-1.5 opacity-0 group-hover:opacity-100 hover:bg-blue-50 text-slate-300 hover:text-blue-500 rounded-lg transition-all"><Edit3 size={14} /></button>
|
|
<button onClick={(e) => { e.stopPropagation(); handleDeleteGroup(group.id); }} className="p-1.5 opacity-0 group-hover:opacity-100 hover:bg-rose-50 text-slate-300 hover:text-rose-500 rounded-lg transition-all"><Trash2 size={14} /></button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="lg:col-span-3 space-y-6">
|
|
<div className="bg-white p-6 rounded-2xl shadow-sm border border-slate-100 flex flex-col h-[650px]">
|
|
{selectedGroup ? (
|
|
<>
|
|
<div className="flex justify-between items-center mb-8">
|
|
<div>
|
|
<h3 className="text-[20px] font-black text-slate-900 italic tracking-tighter uppercase mb-2">{selectedGroup.name}</h3>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] font-black text-slate-400 tracking-widest bg-slate-50 px-3 py-1 rounded-full">편집 모드</span>
|
|
</div>
|
|
</div>
|
|
<div className="relative w-64">
|
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
|
|
<input type="text" placeholder="종목 추가..." className="w-full pl-11 pr-4 py-2 bg-slate-50 border-2 border-transparent rounded-xl focus:border-blue-500 focus:bg-white outline-none text-[12px] font-bold shadow-sm" value={stockSearch} onChange={(e) => setStockSearch(e.target.value)} />
|
|
{filteredSearchStocks.length > 0 && (
|
|
<div className="absolute top-full left-0 right-0 mt-2 bg-white border border-slate-100 shadow-xl rounded-2xl overflow-hidden z-[50]">
|
|
{filteredSearchStocks.map(s => (
|
|
<div key={s.code} onClick={() => handleAddStockToGroup(s.code)} className="p-3 hover:bg-blue-50 cursor-pointer flex justify-between items-center border-b last:border-none border-slate-50">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 bg-slate-100 rounded-lg flex items-center justify-center font-black text-slate-400 text-[9px]">{s.code.substring(0,2)}</div>
|
|
<p className="font-black text-slate-800 text-[12px]">{s.name}</p>
|
|
</div>
|
|
<Plus size={14} className="text-blue-600" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto pr-3 scrollbar-hide">
|
|
<table className="w-full text-left">
|
|
<tbody className="divide-y divide-slate-50">
|
|
{selectedGroup.codes.map((code, idx) => {
|
|
const stock = stocks.find(s => s.code === code);
|
|
if (!stock) return null;
|
|
return (
|
|
<StockRow
|
|
key={code}
|
|
stock={stock}
|
|
onTrade={() => {}}
|
|
onToggleWatchlist={() => handleRemoveStockFromGroup(code)}
|
|
isWatchlisted={true}
|
|
/>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center h-full opacity-20"><Star size={80} strokeWidth={1} /><p className="text-lg font-black uppercase tracking-widest mt-6">그룹을 선택하세요</p></div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 모달은 기존 코드 유지 (생략 가능하나 유저 요청에 따라 전체 포함) */}
|
|
{showAddGroupModal && (
|
|
<div className="fixed inset-0 z-[150] bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-6">
|
|
<div className="bg-white w-full max-w-sm rounded-2xl p-6 shadow-2xl border border-slate-200">
|
|
<div className="flex justify-between items-center mb-6"><h3 className="text-[18px] font-black text-slate-900 uppercase tracking-tight">새 그룹 생성</h3><button onClick={() => setShowAddGroupModal(false)}><X size={24} className="text-slate-400" /></button></div>
|
|
<input type="text" className="w-full p-4 bg-slate-50 border-2 border-transparent focus:border-blue-500 rounded-xl font-black text-sm outline-none" placeholder="그룹 명칭" value={newGroupName} onChange={(e) => setNewGroupName(e.target.value)} />
|
|
<button onClick={handleAddGroup} className="w-full py-3 bg-blue-600 text-white rounded-xl font-black text-sm mt-6 shadow-lg shadow-blue-100 active:scale-[0.98] transition-all">그룹 생성</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showRenameModal && (
|
|
<div className="fixed inset-0 z-[150] bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-6">
|
|
<div className="bg-white w-full max-w-sm rounded-2xl p-6 shadow-2xl border border-slate-200">
|
|
<div className="flex justify-between items-center mb-6"><h3 className="text-[18px] font-black text-slate-900 uppercase tracking-tight">그룹 명칭 변경</h3><button onClick={() => setShowRenameModal(false)}><X size={24} className="text-slate-400" /></button></div>
|
|
<input type="text" className="w-full p-4 bg-slate-50 border-2 border-transparent focus:border-blue-500 rounded-xl font-black text-sm outline-none" placeholder="새로운 명칭" value={renameValue} onChange={(e) => setRenameValue(e.target.value)} />
|
|
<button onClick={handleRenameGroup} className="w-full py-3 bg-blue-600 text-white rounded-xl font-black text-sm mt-6 shadow-lg shadow-blue-100 active:scale-[0.98] transition-all">변경 저장</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default WatchlistManagement;
|