// ============================================================ // 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 ( Sem dados ); } 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 ( {paths.map((p, i) => ( ))} {label && ( <> {label} {total} )} ); } // ── 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 (
Carregando portfólio…
); 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) => (
{k.label}
{k.value}
{k.sub}
))}
{/* ── 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 => (
{d.label}
{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;