import { useState, useCallback, useRef, useMemo } from 'react'; import { getPortfolio, addPortfolio, updatePortfolio, deletePortfolio, upsertCash, deleteCash, } from '../../../api'; import { emptyPortfolioForm } from '../stockUtils'; export default function usePortfolio() { const [portfolio, setPortfolio] = useState(null); const [portfolioLoading, setPortfolioLoading] = useState(false); const [portfolioError, setPortfolioError] = useState(''); const [portfolioLoaded, setPortfolioLoaded] = useState(false); /* add form */ const [addForm, setAddForm] = useState({ ...emptyPortfolioForm }); const [addFormOpen, setAddFormOpen] = useState(false); const [addLoading, setAddLoading] = useState(false); const [addError, setAddError] = useState(''); /* edit */ const [editingId, setEditingId] = useState(null); const [editForm, setEditForm] = useState({}); const [editLoading, setEditLoading] = useState(false); const editOrigRef = useRef({}); /* delete / sell confirm */ const [deleteConfirmId, setDeleteConfirmId] = useState(null); const [sellConfirmId, setSellConfirmId] = useState(null); const [sellLoading, setSellLoading] = useState(false); /* cash */ const [cashForm, setCashForm] = useState({ broker: '', cash: '' }); const [cashSaving, setCashSaving] = useState(false); const [cashError, setCashError] = useState(''); const [cashEditingBroker, setCashEditingBroker] = useState(null); const [cashEditingValue, setCashEditingValue] = useState(''); const [cashEditSaving, setCashEditSaving] = useState(false); /* derived */ const portfolioHoldings = portfolio?.holdings ?? []; const portfolioSummary = portfolio?.summary ?? {}; const cashList = portfolio?.cash ?? []; const totalCash = portfolioSummary.total_cash ?? null; const totalAssets = portfolioSummary.total_assets ?? null; const brokerGroups = useMemo(() => { const map = {}; for (const item of portfolioHoldings) { const broker = item.broker || '기타'; if (!map[broker]) map[broker] = []; map[broker].push(item); } return Object.entries(map).sort(([a], [b]) => a.localeCompare(b)); }, [portfolioHoldings]); const brokerColors = useMemo(() => { const palette = [ { border: 'rgba(129,140,248,0.5)', bg: 'rgba(129,140,248,0.06)' }, { border: 'rgba(251,191,36,0.5)', bg: 'rgba(251,191,36,0.06)' }, { border: 'rgba(52,211,153,0.5)', bg: 'rgba(52,211,153,0.06)' }, { border: 'rgba(244,114,182,0.5)', bg: 'rgba(244,114,182,0.06)' }, { border: 'rgba(251,146,60,0.5)', bg: 'rgba(251,146,60,0.06)' }, { border: 'rgba(139,92,246,0.5)', bg: 'rgba(139,92,246,0.06)' }, ]; const map = {}; brokerGroups.forEach(([broker], i) => { map[broker] = palette[i % palette.length]; }); return map; }, [brokerGroups]); const getBrokerSummary = (items) => { // totalBuy: 요약 표시용 (매입가 purchase_price 기준) // totalCostBasis: 손익 계산용 (평균단가 avg_price 기준) let totalBuy = 0, totalCostBasis = 0, totalEvalAmt = 0, hasNullPrice = false; for (const item of items) { const qty = item.quantity ?? 0; const purchase = item.purchase_price ?? item.avg_price ?? 0; // 총 매입 = 종목별 매입가의 단순 합 (수량 미곱산) totalBuy += purchase; totalCostBasis += (item.avg_price ?? 0) * qty; if (item.eval_amount != null) totalEvalAmt += item.eval_amount; else hasNullPrice = true; } const totalProfit = totalEvalAmt - totalCostBasis; const totalProfitRate = totalCostBasis > 0 ? (totalProfit / totalCostBasis) * 100 : 0; return { totalBuy, totalEval: totalEvalAmt, totalProfit, totalProfitRate, hasNullPrice }; }; /* loaders */ const loadPortfolio = useCallback(async () => { setPortfolioLoading(true); setPortfolioError(''); try { const data = await getPortfolio(); setPortfolio(data); setPortfolioLoaded(true); } catch (err) { setPortfolioError(err?.message ?? String(err)); } finally { setPortfolioLoading(false); } }, []); /* actions */ const handleAddSubmit = async (e) => { e.preventDefault(); setAddLoading(true); setAddError(''); try { await addPortfolio({ broker: addForm.broker.trim(), ticker: addForm.ticker.trim(), name: addForm.name.trim(), quantity: Number(addForm.quantity), avg_price: Number(addForm.avg_price), purchase_price: addForm.purchase_price === '' || addForm.purchase_price == null ? Number(addForm.avg_price) : Number(addForm.purchase_price), }); setAddForm({ ...emptyPortfolioForm }); setAddFormOpen(false); await loadPortfolio(); } catch (err) { setAddError(err?.message ?? String(err)); } finally { setAddLoading(false); } }; const handleEditStart = (item) => { setEditingId(item.id); const data = { quantity: item.quantity, avg_price: item.avg_price, purchase_price: item.purchase_price ?? item.avg_price, broker: item.broker, name: item.name, }; setEditForm(data); editOrigRef.current = { ...data }; }; const handleEditSave = async (id) => { setEditLoading(true); try { const orig = editOrigRef.current ?? {}; const diff = {}; for (const key of Object.keys(editForm)) { if (editForm[key] !== orig[key]) diff[key] = editForm[key]; } if (Object.keys(diff).length === 0) { setEditingId(null); return; } await updatePortfolio(id, diff); setEditingId(null); await loadPortfolio(); } catch (err) { const msg = err?.message ?? String(err); if (msg.includes('404') || msg.includes('not found')) { alert('해당 종목을 찾을 수 없습니다. 이미 삭제되었을 수 있습니다.'); await loadPortfolio(); } else { alert('수정 실패: ' + msg); } } finally { setEditLoading(false); } }; const handleDelete = async (id) => { try { await deletePortfolio(id); setDeleteConfirmId(null); await loadPortfolio(); } catch (err) { const msg = err?.message ?? String(err); if (msg.includes('404') || msg.includes('not found')) { alert('해당 종목을 찾을 수 없습니다. 이미 삭제되었을 수 있습니다.'); setDeleteConfirmId(null); await loadPortfolio(); } else { alert('삭제 실패: ' + msg); } } }; /* cash actions */ const handleCashSave = async (e) => { e.preventDefault(); if (!cashForm.broker.trim() || cashForm.cash === '') return; setCashSaving(true); setCashError(''); try { await upsertCash(cashForm.broker.trim(), Number(cashForm.cash)); setCashForm({ broker: '', cash: '' }); await loadPortfolio(); } catch (err) { setCashError(err?.message ?? String(err)); } finally { setCashSaving(false); } }; const handleCashDelete = async (broker) => { try { await deleteCash(broker); await loadPortfolio(); } catch (err) { alert('예수금 삭제 실패: ' + (err?.message ?? String(err))); } }; const handleCashInlineEdit = (item) => { setCashEditingBroker(item.broker); setCashEditingValue(String(item.cash ?? '')); }; const handleCashInlineSave = async (broker) => { if (cashEditingValue === '') return; setCashEditSaving(true); try { await upsertCash(broker, Number(cashEditingValue)); setCashEditingBroker(null); setCashEditingValue(''); await loadPortfolio(); } catch (err) { alert('예수금 수정 실패: ' + (err?.message ?? String(err))); } finally { setCashEditSaving(false); } }; const handleCashInlineCancel = () => { setCashEditingBroker(null); setCashEditingValue(''); }; /* sell (현재가 매도) */ const handleSell = async (item, { cashList: cl, loadSellHistoryAfter }) => { const sellPrice = item.current_price ?? item.avg_price; const avgPrice = item.avg_price ?? 0; const qty = item.quantity ?? 0; const saleAmount = sellPrice * qty; const buyAmount = avgPrice * qty; const realizedProfit = saleAmount - buyAmount; const realizedRate = buyAmount > 0 ? (realizedProfit / buyAmount) * 100 : 0; const broker = item.broker ?? ''; setSellLoading(true); try { const existing = cl.find((c) => c.broker === broker); const newCash = (existing?.cash ?? 0) + saleAmount; await upsertCash(broker, newCash); await deletePortfolio(item.id); setSellConfirmId(null); await loadPortfolio(); if (loadSellHistoryAfter) { await loadSellHistoryAfter({ broker, ticker: item.ticker ?? '', name: item.name ?? item.ticker ?? 'N/A', quantity: qty, avg_price: avgPrice, sell_price: sellPrice, buy_amount: buyAmount, sell_amount: saleAmount, realized_profit: realizedProfit, realized_rate: realizedRate, sold_at: new Date().toISOString(), }); } } catch (err) { alert('매도 처리 실패: ' + (err?.message ?? String(err))); } finally { setSellLoading(false); } }; return { portfolio, portfolioLoading, portfolioError, portfolioLoaded, loadPortfolio, portfolioHoldings, portfolioSummary, cashList, totalCash, totalAssets, addForm, setAddForm, addFormOpen, setAddFormOpen, addLoading, addError, handleAddSubmit, editingId, setEditingId, editForm, setEditForm, editLoading, handleEditStart, handleEditSave, deleteConfirmId, setDeleteConfirmId, handleDelete, sellConfirmId, setSellConfirmId, sellLoading, handleSell, cashForm, setCashForm, cashSaving, cashError, handleCashSave, handleCashDelete, cashEditingBroker, cashEditingValue, setCashEditingValue, cashEditSaving, handleCashInlineEdit, handleCashInlineSave, handleCashInlineCancel, brokerGroups, brokerColors, getBrokerSummary, }; }