285 lines
11 KiB
JavaScript
285 lines
11 KiB
JavaScript
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,
|
|
};
|
|
}
|