// ============================================================
// FINAFAMILY — Investimentos (Módulo completo v2)
// ============================================================
const { useState, useEffect, useMemo } = React;
// ── Constantes ───────────────────────────────────────────────
const INV_TYPES = [
{ value: 'CDB', label: 'CDB', color: '#3B82F6' },
{ value: 'LCI', label: 'LCI', color: '#10B981' },
{ value: 'LCA', label: 'LCA', color: '#059669' },
{ value: 'Tesouro_Direto',label: 'Tesouro Direto',color: '#F59E0B' },
{ value: 'FII', label: 'FII', color: '#8B5CF6' },
{ value: 'Acoes', label: 'Ações', color: '#EF4444' },
{ value: 'Poupanca', label: 'Poupança', color: '#06B6D4' },
{ value: 'Previdencia', label: 'Previdência', color: '#EC4899' },
{ value: 'Cripto', label: 'Cripto', color: '#F97316' },
{ value: 'Outros', label: 'Outros', color: '#6B7280' },
];
const YIELD_TYPES = [
{ value: 'pre_fixado', label: 'Pré-fixado', color: '#3B82F6', icon: 'Lock' },
{ value: 'pos_fixado', label: 'Pós-fixado', color: '#10B981', icon: 'TrendingUp' },
{ value: 'ipca_plus', label: 'IPCA+', color: '#8B5CF6', icon: 'BarChart2' },
{ value: 'variavel', label: 'Variável', color: '#F59E0B', icon: 'Activity' },
];
const GEOGRAPHIES = [
{ value: 'nacional', label: 'Nacional', color: 'var(--accent)', flag: '🇧🇷' },
{ value: 'internacional', label: 'Internacional', color: '#3B82F6', flag: '🌎' },
];
const PURPOSES = [
{ value: 'emergency', label: 'Reserva de Emergência', icon: 'ShieldCheck' },
{ value: 'retirement', label: 'Aposentadoria', icon: 'TrendingUp' },
{ value: 'passive', label: 'Renda Passiva', icon: 'DollarSign' },
{ value: 'travel', label: 'Viagem', icon: 'Plane' },
{ value: 'education', label: 'Educação', icon: 'GraduationCap' },
{ value: 'other', label: 'Outro', icon: 'CheckCircle' },
];
const RATE_INDEXES = ['CDI', 'IPCA', 'SELIC', 'IGP-M'];
function invColor(type) {
return INV_TYPES.find(t => t.value === type)?.color || '#6B7280';
}
function yieldColor(yt) {
return YIELD_TYPES.find(t => t.value === yt)?.color || '#6B7280';
}
function yieldLabel(yt) {
return YIELD_TYPES.find(t => t.value === yt)?.label || yt;
}
function invLabel(t) {
return INV_TYPES.find(x => x.value === t)?.label || t;
}
function purposeLabel(p) {
return PURPOSES.find(x => x.value === p)?.label || p;
}
// ── Cálculo de valor projetado ────────────────────────────────
function calcProjectedValue(inv) {
const base = parseFloat(inv.value);
if (!inv.rateValue || parseFloat(inv.rateValue) === 0 || inv.yieldType === 'variavel') return base;
const refDate = inv.startDate ? parseLocalDate(inv.startDate) : new Date(inv.createdAt);
const today = new Date();
const daysDiff = Math.floor((today - refDate) / 86400000);
if (daysDiff <= 0) return base;
const annualRate = parseFloat(inv.rateValue);
const dailyRate = Math.pow(1 + annualRate / 100, 1 / 365) - 1;
return base * Math.pow(1 + dailyRate, daysDiff);
}
// Mesma taxa mensal do Dashboard (finance-calc.js): renda fixa pela taxa anual,
// renda variável pela rentabilidade mensal estimada.
function calcMonthReturn(inv) {
return (parseFloat(inv.value) || 0) * investmentMonthlyRatePct(inv) / 100;
}
// ── Donut Chart SVG ────────────────────────────────────────────
function DonutChart({ data, size = 140, label, total }) {
const cx = size / 2, cy = size / 2;
const r = size / 2 - 14;
const validData = data.filter(d => d.value > 0);
if (!validData.length) {
return (
);
}
const totalVal = validData.reduce((s, d) => s + d.value, 0);
let angle = -Math.PI / 2;
const paths = validData.map(d => {
const sweep = (d.value / totalVal) * 2 * Math.PI;
const x1 = cx + r * Math.cos(angle);
const y1 = cy + r * Math.sin(angle);
angle += sweep;
const x2 = cx + r * Math.cos(angle);
const y2 = cy + r * Math.sin(angle);
const large = sweep > Math.PI ? 1 : 0;
return { path: `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2} Z`, color: d.color, label: d.label, pct: d.value / totalVal * 100 };
});
return (
);
}
// ── Barra de rebalanceamento ───────────────────────────────────
function GoalBar({ goal, actual }) {
const diff = actual - goal.targetPct;
const ok = Math.abs(diff) <= goal.tolerancePct;
const barColor = ok ? '#10B981' : Math.abs(diff) <= goal.tolerancePct * 2 ? '#F59E0B' : '#EF4444';
const width = Math.min(100, Math.max(0, actual));
return (
{goal.keyLabel}
Meta: {goal.targetPct}%
{actual.toFixed(1)}%
{!ok && (
⚠️
)}
{/* Indicador da meta */}
);
}
// ── Card de investimento ───────────────────────────────────────
function InvCard({ inv, onEdit, onDelete, onAporte, selected, onSelect, readOnly }) {
const projected = calcProjectedValue(inv);
const monthReturn = calcMonthReturn(inv);
const base = parseFloat(inv.value);
const gain = projected - base;
const color = invColor(inv.type);
const yt = YIELD_TYPES.find(y => y.value === inv.yieldType);
return (
onSelect(inv.id === selected ? null : inv.id)}
style={{
background: 'var(--surface-0)',
border: `1.5px solid ${selected ? color : 'var(--border)'}`,
borderRadius: 14,
padding: '16px 18px',
cursor: 'pointer',
transition: 'all .18s',
position: 'relative',
overflow: 'hidden',
}}
>
{/* Barra colorida lateral */}
{inv.name}
{invLabel(inv.type)}
{yt?.label || inv.yieldType}
{inv.geography === 'internacional' && (
🌎 Intl.
)}
{fmtBRL(projected)}
{gain > 0.01 && (
+{fmtBRL(gain)} projetado
)}
{inv.bank}
{inv.rateValue > 0 && (
{inv.yieldType === 'pos_fixado' && inv.ratePercent
? `${inv.ratePercent}% do ${inv.rateIndex || 'CDI'}`
: `${parseFloat(inv.rateValue).toFixed(2)}% a.a.`
}
)}
{monthReturn > 0 && (
~{fmtBRL(monthReturn)}/mês
)}
{/* Detalhe expandido */}
{selected && (
{inv.startDate && (
Início
{parseLocalDate(inv.startDate).toLocaleDateString('pt-BR')}
)}
{inv.maturityDate && (
Vencimento
{parseLocalDate(inv.maturityDate).toLocaleDateString('pt-BR')}
)}
Finalidade
{purposeLabel(inv.purpose)}
Responsável
{inv.member?.name || '—'}
{/* Aportes recentes */}
{inv.contributions?.length > 0 && (
Movimentações recentes
{inv.contributions.slice(0, 3).map(c => (
{parseLocalDate(c.date).toLocaleDateString('pt-BR')} · {c.type}
{c.type === 'resgate' ? '-' : '+'}{fmtBRL(c.value)}
))}
)}
{readOnly ? (
🔒 Compartilhado por {inv.member?.name || 'outro membro'} · somente leitura
) : (
)}
)}
);
}
// ── Modal de Aporte/Resgate ────────────────────────────────────
function AporteModal({ inv, onClose, onSave }) {
const today = todayLocalISO();
const [form, setForm] = useState({ type: 'aporte', value: '', date: today, note: '' });
const [saving, setSaving] = useState(false);
async function handleSave() {
if (!form.value || isNaN(parseFloat(form.value))) return;
setSaving(true);
try {
await onSave(inv.id, { ...form, value: parseFloat(form.value) });
onClose();
} finally { setSaving(false); }
}
const color = form.type === 'resgate' ? '#EF4444' : '#10B981';
return (
Movimentação
{inv.name}
{/* Tipo */}
{['aporte', 'resgate', 'rendimento'].map(t => (
))}
setForm(f => ({ ...f, value: e.target.value }))}
style={{ width: '100%', padding: '9px 10px', borderRadius: 8, border: '1px solid var(--border)', background: 'var(--surface-1)', color: 'var(--text-1)', fontSize: 16, boxSizing: 'border-box' }}
placeholder="0,00"
/>
setForm(f => ({ ...f, date: e.target.value }))}
style={{ width: '100%', padding: '9px 10px', borderRadius: 8, border: '1px solid var(--border)', background: 'var(--surface-1)', color: 'var(--text-1)', fontSize: 16, boxSizing: 'border-box' }}
/>
setForm(f => ({ ...f, note: e.target.value.toUpperCase() }))}
style={{ width: '100%', padding: '9px 10px', borderRadius: 8, border: '1px solid var(--border)', background: 'var(--surface-1)', color: 'var(--text-1)', fontSize: 16, boxSizing: 'border-box', textTransform: 'uppercase' }}
placeholder="Ex: Aporte mensal, rendimento de out/24..."
/>
);
}
// ── Modal de Metas ─────────────────────────────────────────────
function GoalsModal({ goals, onClose, onSave, onDelete }) {
const [dim, setDim] = useState('asset_type');
const [key, setKey] = useState('');
const [targetPct, setTargetPct] = useState('');
const [tolerancePct, setTolerancePct] = useState('5');
const [saving, setSaving] = useState(false);
const dimOptions = {
asset_type: INV_TYPES.map(t => ({ value: t.value, label: t.label })),
yield_type: YIELD_TYPES.map(t => ({ value: t.value, label: t.label })),
geography: GEOGRAPHIES.map(t => ({ value: t.value, label: t.label })),
};
async function handleSave() {
if (!key || !targetPct) return;
setSaving(true);
try {
await onSave({ dimension: dim, key, targetPct: parseFloat(targetPct), tolerancePct: parseFloat(tolerancePct || '5') });
setKey(''); setTargetPct('');
} finally { setSaving(false); }
}
return (
🎯 Metas de Portfólio
Defina percentuais alvo para manter a carteira balanceada. O sistema alertará quando o portfólio desviar da meta.
{/* Dimensão */}
{[['asset_type','Tipo de Ativo'], ['yield_type','Tipo de Renda'], ['geography','Geografia']].map(([v, l]) => (
))}
setTargetPct(e.target.value)}
style={{ width: '100%', padding: '9px 10px', borderRadius: 8, border: '1px solid var(--border)', background: 'var(--surface-1)', color: 'var(--text-1)', fontSize: 16, boxSizing: 'border-box' }}
placeholder="50" />
setTolerancePct(e.target.value)}
style={{ width: '100%', padding: '9px 10px', borderRadius: 8, border: '1px solid var(--border)', background: 'var(--surface-1)', color: 'var(--text-1)', fontSize: 16, boxSizing: 'border-box' }}
placeholder="5" />
{/* Lista de metas existentes */}
{goals.length > 0 && (
Metas definidas
{goals.map(g => (
{g.keyLabel} ({g.dimensionLabel})
{parseFloat(g.targetPct).toFixed(0)}%
±{parseFloat(g.tolerancePct).toFixed(0)}%
))}
)}
);
}
// ── Helpers de formulário (fora do componente para evitar remount a cada keystroke) ──
function InvFormSection({ title }) {
return (
{title}
);
}
// Rótulo ligado ao campo (htmlFor/id) — mesmo problema e correção do FormField.
function InvFormField({ label, children }) {
const autoId = React.useId();
let fieldId = null;
const linked = React.Children.map(children, child => {
if (fieldId || !React.isValidElement(child) || !['input', 'select', 'textarea'].includes(child.type)) return child;
fieldId = child.props.id || autoId;
return React.cloneElement(child, { id: fieldId });
});
return (
{linked}
);
}
const INV_INP = { width: '100%', padding: '9px 10px', borderRadius: 8, border: '1px solid var(--border)', background: 'var(--surface-1)', color: 'var(--text-1)', fontSize: 16, boxSizing: 'border-box' };
// ── Formulário de Investimento ─────────────────────────────────
const EMPTY_FORM = {
name: '', type: 'CDB', yieldType: 'pre_fixado', geography: 'nacional',
purpose: 'emergency', bank: '', value: '', monthlyRate: '0',
rateValue: '', rateIndex: 'CDI', ratePercent: '',
startDate: todayLocalISO(),
maturityDate: '', visibility: 'private',
};
function InvFormModal({ inv, onClose, onSave }) {
const [form, setForm] = useState(() => inv ? {
name: inv.name, type: inv.type, yieldType: inv.yieldType || 'variavel',
geography: inv.geography || 'nacional', purpose: inv.purpose,
bank: inv.bank, value: String(parseFloat(inv.value)),
monthlyRate: String(parseFloat(inv.monthlyRate || 0)),
rateValue: inv.rateValue ? String(parseFloat(inv.rateValue)) : '',
rateIndex: inv.rateIndex || 'CDI',
ratePercent: inv.ratePercent ? String(parseFloat(inv.ratePercent)) : '',
startDate: inv.startDate ? new Date(inv.startDate).toISOString().split('T')[0] : todayLocalISO(),
maturityDate: inv.maturityDate ? new Date(inv.maturityDate).toISOString().split('T')[0] : '',
visibility: inv.visibility || 'private', // privado é o padrão; compartilhar é escolha explícita
} : { ...EMPTY_FORM });
const [saving, setSaving] = useState(false);
const isFixedIncome = form.yieldType !== 'variavel';
async function handleSave() {
if (!form.name || !form.bank || !form.value) return;
setSaving(true);
try {
await onSave({
...form,
value: parseFloat(form.value) || 0,
monthlyRate: parseFloat(form.monthlyRate) || 0,
rateValue: parseFloat(form.rateValue) || 0,
ratePercent: form.ratePercent ? parseFloat(form.ratePercent) : undefined,
maturityDate: form.maturityDate || null,
});
onClose();
} finally { setSaving(false); }
}
// Section, F e inp definidos fora do componente (acima) para não causar remount
return (
{inv ? '✏️ Editar Investimento' : '➕ Novo Investimento'}
setForm(f => ({ ...f, name: e.target.value.toUpperCase() }))} style={{ ...INV_INP, textTransform: 'uppercase' }} placeholder="Ex: CDB Nubank 120% CDI" />
setForm(f => ({ ...f, bank: e.target.value.toUpperCase() }))} style={{ ...INV_INP, textTransform: 'uppercase' }} placeholder="Ex: Nubank, XP, BTG..." />
setForm(f => ({ ...f, value: e.target.value }))} style={{ ...INV_INP }} placeholder="0,00" />
{isFixedIncome ? (
form.yieldType === 'pos_fixado' ? (
<>
setForm(f => ({ ...f, ratePercent: e.target.value }))} style={{ ...INV_INP }} placeholder="110" />
setForm(f => ({ ...f, rateValue: e.target.value }))} style={{ ...INV_INP }} placeholder="12.5 (para cálculo)" />
>
) : form.yieldType === 'ipca_plus' ? (
<>
setForm(f => ({ ...f, rateValue: e.target.value }))} style={{ ...INV_INP }} placeholder="6.5 (spread sobre IPCA)" />
>
) : (
setForm(f => ({ ...f, rateValue: e.target.value }))} style={{ ...INV_INP }} placeholder="12.00" />
)
) : (
setForm(f => ({ ...f, monthlyRate: e.target.value }))} style={{ ...INV_INP }} placeholder="0.00" />
)}
setForm(f => ({ ...f, startDate: e.target.value }))} style={{ ...INV_INP }} />
setForm(f => ({ ...f, maturityDate: e.target.value }))} style={{ ...INV_INP }} />
);
}
// ── Tela Principal ─────────────────────────────────────────────
function Investimentos() {
const { currentMember } = useApp();
const [investments, setInvestments] = useState([]);
const [goals, setGoals] = useState([]);
const [summary, setSummary] = useState(null);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState(null);
const [filterType, setFilterType] = useState('todos');
const [filterYield, setFilterYield] = useState('todos');
const [showForm, setShowForm] = useState(false);
const [editInv, setEditInv] = useState(null);
const [aporteInv, setAporteInv] = useState(null);
const [showGoals, setShowGoals] = useState(false);
const [deleteId, setDeleteId] = useState(null);
const [toast, setToast] = useState('');
const showToast = msg => { setToast(msg); setTimeout(() => setToast(''), 3000); };
// ── Carregar dados ──────────────────────────────────────────
async function loadAll() {
setLoading(true);
try {
const [{ data }, goalsData, sumData] = await Promise.all([
apiFetch('/investments'),
apiFetch('/portfolio-goals'),
apiFetch('/investments/summary'),
]);
setInvestments(data || []);
setGoals(goalsData || []);
setSummary(sumData || null);
} catch (e) {
console.error(e);
} finally { setLoading(false); }
}
useEffect(() => { loadAll(); }, []);
// ── CRUD ────────────────────────────────────────────────────
async function handleSaveInv(form) {
if (editInv) {
await apiFetch(`/investments/${editInv.id}`, { method: 'PATCH', body: form });
showToast('Investimento atualizado!');
} else {
await apiFetch('/investments', { method: 'POST', body: form });
showToast('Investimento cadastrado!');
}
setEditInv(null);
loadAll();
}
async function handleDelete(id) {
setDeleteId(null);
await apiFetch(`/investments/${id}`, { method: 'DELETE' });
setInvestments(prev => prev.filter(i => i.id !== id));
setSelected(null);
showToast('Investimento removido.');
loadAll();
}
async function handleAporte(invId, form) {
const res = await apiFetch(`/investments/${invId}/contributions`, { method: 'POST', body: form });
showToast('Movimentação registrada!');
loadAll();
}
async function handleSaveGoal(data) {
await apiFetch('/portfolio-goals', { method: 'POST', body: data });
showToast('Meta salva!');
loadAll();
}
async function handleDeleteGoal(id) {
await apiFetch(`/portfolio-goals/${id}`, { method: 'DELETE' });
setGoals(prev => prev.filter(g => g.id !== id));
}
// ── Computação de portfólio ──────────────────────────────────
// Só os PRÓPRIOS investimentos entram nas somas/gráficos do portfólio — os
// que outro membro escolheu compartilhar (isOwn: false) são só leitura e
// aparecem numa seção separada, para não inflar o "meu" patrimônio.
const enriched = useMemo(() => investments.filter(i => i.isOwn !== false).map(inv => ({
...inv,
projectedValue: calcProjectedValue(inv),
})), [investments]);
const sharedByOthers = useMemo(() => investments.filter(i => i.isOwn === false).map(inv => ({
...inv,
projectedValue: calcProjectedValue(inv),
})), [investments]);
const totalProjected = useMemo(() =>
enriched.reduce((s, i) => s + i.projectedValue, 0), [enriched]);
const totalMonthReturn = useMemo(() =>
enriched.reduce((s, i) => s + calcMonthReturn(i), 0), [enriched]);
// Breakdown para gráficos
const byType = useMemo(() => {
const map = {};
enriched.forEach(i => { map[i.type] = (map[i.type] || 0) + i.projectedValue; });
return INV_TYPES.filter(t => map[t.value]).map(t => ({ label: t.label, value: map[t.value], color: t.color }));
}, [enriched]);
const byYield = useMemo(() => {
const map = {};
enriched.forEach(i => { map[i.yieldType] = (map[i.yieldType] || 0) + i.projectedValue; });
return YIELD_TYPES.filter(t => map[t.value]).map(t => ({ label: t.label, value: map[t.value], color: t.color }));
}, [enriched]);
const byGeo = useMemo(() => {
const map = {};
enriched.forEach(i => { map[i.geography] = (map[i.geography] || 0) + i.projectedValue; });
return GEOGRAPHIES.filter(t => map[t.value]).map(t => ({ label: t.label, value: map[t.value], color: t.color }));
}, [enriched]);
// ── Breakdown por instituição financeira ────────────────────
const byBank = useMemo(() => {
const map = {};
enriched.forEach(i => {
const key = (i.bank || 'Sem instituição').trim();
if (!map[key]) map[key] = { total: 0, count: 0, types: new Set() };
map[key].total += i.projectedValue;
map[key].count += 1;
map[key].types.add(i.type);
});
return Object.entries(map)
.map(([bank, d]) => ({
bank,
total: d.total,
count: d.count,
pct: totalProjected > 0 ? d.total / totalProjected * 100 : 0,
types: [...d.types],
}))
.sort((a, b) => b.total - a.total);
}, [enriched, totalProjected]);
// ── Goals com percentuais reais ─────────────────────────────
const goalsEnriched = useMemo(() => {
const byTypeMap = {};
const byYieldMap = {};
const byGeoMap = {};
enriched.forEach(i => {
byTypeMap[i.type] = (byTypeMap[i.type] || 0) + i.projectedValue;
byYieldMap[i.yieldType] = (byYieldMap[i.yieldType] || 0) + i.projectedValue;
byGeoMap[i.geography] = (byGeoMap[i.geography] || 0) + i.projectedValue;
});
const dimLookup = {
asset_type: { ...Object.fromEntries(INV_TYPES.map(t => [t.value, t.label])), map: byTypeMap },
yield_type: { ...Object.fromEntries(YIELD_TYPES.map(t => [t.value, t.label])), map: byYieldMap },
geography: { ...Object.fromEntries(GEOGRAPHIES.map(t => [t.value, t.label])), map: byGeoMap },
};
const dimLabel = { asset_type: 'Tipo', yield_type: 'Renda', geography: 'Geo' };
return goals.map(g => {
const dim = dimLookup[g.dimension] || {};
const actual = totalProjected > 0 ? ((dim.map?.[g.key] || 0) / totalProjected * 100) : 0;
return {
...g,
keyLabel: dim[g.key] || g.key,
dimensionLabel: dimLabel[g.dimension] || g.dimension,
actual,
};
});
}, [goals, enriched, totalProjected]);
const outOfBalance = goalsEnriched.filter(g => Math.abs(g.actual - parseFloat(g.targetPct)) > parseFloat(g.tolerancePct));
// ── Filtros ─────────────────────────────────────────────────
const filtered = useMemo(() => enriched.filter(i => {
if (filterType !== 'todos' && i.type !== filterType) return false;
if (filterYield !== 'todos' && i.yieldType !== filterYield) return false;
return true;
}), [enriched, filterType, filterYield]);
// ── Render ──────────────────────────────────────────────────
if (loading) return (
);
const chipStyle = (active) => ({
padding: '5px 12px', borderRadius: 20, fontSize: 12, cursor: 'pointer', fontWeight: 500,
border: `1px solid ${active ? 'var(--success)' : 'var(--border)'}`,
background: active ? 'var(--success-bg)' : 'transparent',
color: active ? 'var(--success)' : 'var(--text-3)',
});
return (
{/* ── KPIs ─────────────────────────────────────────────── */}
{[
{ label: 'Total Investido', value: fmtBRL(totalProjected), icon: 'Layers', color: 'var(--accent)', sub: `${enriched.length} ativos` },
{ label: 'Rendimento do Mês', value: fmtBRL(totalMonthReturn), icon: 'TrendingUp', color: '#10B981', sub: totalProjected ? `${(totalMonthReturn / totalProjected * 100).toFixed(2)}% ao mês` : '—' },
{ label: 'Rendimento do Ano', value: fmtBRL(summary?.yearReturn || 0), icon: 'BarChart2', color: '#F59E0B', sub: totalProjected && summary?.yearReturn ? `${(summary.yearReturn / totalProjected * 100).toFixed(2)}% no ano` : '—' },
{ label: 'Alertas de Rebalance', value: goals.length === 0 ? 'Sem metas' : outOfBalance.length > 0 ? `${outOfBalance.length} alerta${outOfBalance.length > 1 ? 's' : ''}` : '✅ Balanceado', icon: goals.length === 0 ? 'Info' : outOfBalance.length > 0 ? 'AlertTriangle' : 'CheckCircle', color: goals.length === 0 ? '#94A3B8' : outOfBalance.length > 0 ? '#EF4444' : '#10B981', sub: goals.length === 0 ? 'Defina metas de alocação' : outOfBalance.length > 0 ? 'Carteira fora do alvo' : 'Dentro das metas' },
].map((k, i) => (
))}
{/* ── Layout 2 colunas ─────────────────────────────────── */}
{/* ── Breakdown da Carteira ─────────────────────────── */}
📊 Composição da Carteira
{fmtBRL(totalProjected)} total
{[
{ title: 'Por Tipo', data: byType, total: `${byType.length} tipos` },
{ title: 'Por Renda', data: byYield, total: `${byYield.length} categorias` },
{ title: 'Por Geo', data: byGeo, total: `${byGeo.length} regiões` },
].map(({ title, data, total }) => (
{title}
{data.slice(0, 4).map(d => (
{totalProjected > 0 ? (d.value / totalProjected * 100).toFixed(0) : 0}%
))}
{data.length > 4 &&
+{data.length - 4} outros}
))}
{/* ── Metas e Rebalanceamento ───────────────────────── */}
🎯 Metas
{goalsEnriched.length === 0 ? (
🎯
Nenhuma meta definida
Clique em "Gerenciar" para definir targets de alocação
) : (
{goalsEnriched.map(g => (
))}
)}
{outOfBalance.length > 0 && (
⚠️ Rebalanceamento sugerido
{outOfBalance.map(g => {
const diff = g.actual - parseFloat(g.targetPct);
return (
{g.keyLabel}: {diff > 0 ? 'reduzir' : 'aumentar'} {Math.abs(diff).toFixed(1)}%
);
})}
)}
{/* ── Por Instituição Financeira ───────────────────────── */}
{byBank.length > 0 && (
🏦 Total por Instituição
{byBank.length} instituiç{byBank.length === 1 ? 'ão' : 'ões'}
{byBank.map(b => {
const barColor = '#0D9488';
const typeColors = b.types.map(t => invColor(t));
return (
{/* Barra de proporção no topo */}
{/* Ícone de banco gerado com iniciais */}
{b.bank.slice(0, 2).toUpperCase()}
{b.bank}
{b.count} ativo{b.count !== 1 ? 's' : ''}
{b.pct.toFixed(1)}%
{/* Valor total */}
{fmtBRL(b.total)}
{/* Tipos de ativos como chips coloridos */}
{b.types.map(t => (
{invLabel(t)}
))}
);
})}
)}
{/* ── Lista de Investimentos ───────────────────────────── */}
💼 Ativos
{/* Filtros */}
{INV_TYPES.filter(t => enriched.some(i => i.type === t.value)).map(t => (
))}
{YIELD_TYPES.filter(t => enriched.some(i => i.yieldType === t.value)).map(t => (
))}
{/* Lista */}
{filtered.length === 0 ? (
📭
{enriched.length === 0 ? 'Nenhum investimento cadastrado' : 'Nenhum resultado para os filtros'}
{enriched.length === 0 ? 'Clique em "Novo Ativo" para começar a registrar sua carteira.' : 'Tente remover os filtros.'}
) : (
{filtered.map(inv => (
{ setEditInv(i); setShowForm(true); }}
onDelete={id => setDeleteId(id)}
onAporte={i => setAporteInv(i)}
/>
))}
)}
{/* Compartilhados por outros membros — nunca entram nas somas do portfólio acima */}
{sharedByOthers.length > 0 && (
Compartilhado pela família
{sharedByOthers.map(inv => (
{}}
onDelete={() => {}}
onAporte={() => {}}
/>
))}
)}
{/* ── Modais ──────────────────────────────────────────── */}
{showForm && (
{ setShowForm(false); setEditInv(null); }}
onSave={handleSaveInv}
/>
)}
{aporteInv && (
setAporteInv(null)}
onSave={handleAporte}
/>
)}
{showGoals && (
setShowGoals(false)}
onSave={handleSaveGoal}
onDelete={handleDeleteGoal}
/>
)}
{deleteId && (
🗑️
Excluir investimento?
Todos os aportes e histórico serão removidos. Esta ação não pode ser desfeita.
)}
{/* Toast */}
{toast && (
{toast}
)}
);
}
window.Investimentos = Investimentos;