// ============================================================
// FINAFAMILY — Dashboard Screen
// Extras: Balancete card, Dia a Dia do contexto, alertas de notificação
// ============================================================
const { useState, useMemo, useEffect } = React;
// ── Dia a Dia quick form ───────────────────────────────────
function DiaDiaQuickForm({ onClose, onSave }) {
const { categories, paymentMethods, currentMember } = useApp();
const today = todayLocalISO();
const [desc, setDesc] = useState('');
const [value, setValue] = useState('');
const [date, setDate] = useState(today);
const [error, setError] = useState('');
const myPMs = paymentMethods.filter(pm => pm.memberId === currentMember?.id);
const [pmId, setPmId] = useState(myPMs[0]?.id || '');
const [catId, setCatId] = useState(categories[0]?.id || '');
function handleSave() {
if (!desc.trim()) { setError('Informe o que foi gasto'); return; }
if (!value || isNaN(parseFloat(value))) { setError('Valor inválido'); return; }
onSave({
desc: desc.trim(), value: parseFloat(value), date: date || today,
isDiaDia: true, type: 'single', status: 'pending',
memberId: currentMember?.id || '',
categoryId: catId || categories[0]?.id || '',
pmId: pmId || myPMs[0]?.id || '',
visibility: 'private', blocked: false,
});
onClose();
}
return (
);
}
function DiaDiaEditForm({ expense, onClose, onSave, onDelete }) {
const { categories, paymentMethods, currentMember } = useApp();
const [desc, setDesc] = useState(expense.desc);
const [value, setValue] = useState(String(expense.value));
const [catId, setCatId] = useState(expense.categoryId || '');
const [pmId, setPmId] = useState(expense.pmId || '');
const [error, setError] = useState('');
const myPMs = paymentMethods.filter(pm => pm.memberId === currentMember?.id);
function handleSave() {
if (!desc.trim()) { setError('Informe a descrição'); return; }
if (!value || isNaN(parseFloat(value))) { setError('Valor inválido'); return; }
onSave({ desc: desc.trim(), value: parseFloat(value), categoryId: catId, pmId });
}
return (
);
}
function Dashboard({ onNavigate, onOpenBalancete }) {
const {
currentMember, members, monthExpenses, monthIncomes,
totalExpenses, totalIncome, balance, commitment,
expenses, incomes, categories, paymentMethods, investments, currentMonth,
diaDiaUsed, diaDiaLimit, diaDiaPct, diaDiaExpenses,
diaDiaDailyTarget, diaDiaOverPace, diaDiaTargetFromNow,
balancete, notifications, addExpense, updateExpense, deleteExpense,
mySharedDebts, sharedStatus, acceptSharedExpense, acceptSharedExpenses, balanceteExpenses,
fetchMonthExpenses,
} = useApp();
const displayCategories = expenses.some(e => e.origin === 'member_operation' || e.operationId)
? [...categories, { id: 'member_operation', name: 'Valores entre membros', icon: 'Users', color: '#5264de' }]
: categories;
const [selectedCat, setSelectedCat] = useState(null);
const [showAllCategories, setShowAllCategories] = useState(false);
const [toast, setToast] = useState(null);
const [showDiaDiaForm, setShowDiaDiaForm] = useState(false);
const [editingDiaDia, setEditingDiaDia] = useState(null);
const [diaDiaToast, setDiaDiaToast] = useState(null);
// Despesas do mês sob RESPONSABILIDADE do usuário logado — nunca "quem criou".
// Cobre cota de divisão e responsabilidade integral transferida por outra
// pessoa (ex: "CARRO" lançado por outro membro, 100% responsabilidade minha).
// Distinto de "Meios de Pagamento" (fluxo de caixa: tudo que passa pelo MEU
// cartão, mesmo que a responsabilidade seja de outro membro) — as duas visões
// juntas permitem ao dono do cartão diferenciar "o que sai do meu cartão" de
// "o que é efetivamente meu".
const myResponsibilityExpenses = useMemo(() => {
return expenses.filter(e => {
const d = parseLocalDate(e.date);
if (d.getFullYear() !== currentMonth.year || d.getMonth() + 1 !== currentMonth.month) return false;
return expenseResponsibilityRows(e).some(row => row.responsibleMemberId === currentMember.id);
});
}, [expenses, currentMonth, currentMember]);
function myResponsibilityAmount(exp) {
const row = expenseResponsibilityRows(exp).find(row => row.responsibleMemberId === currentMember.id);
return row ? row.amountCents / 100 : 0;
}
const myResponsibilityTotal = useMemo(() =>
myResponsibilityExpenses.reduce((s, e) => s + myResponsibilityAmount(e), 0),
[myResponsibilityExpenses, currentMember]);
const catBreakdown = useMemo(() => {
return displayCategories.map(cat => {
const total = myResponsibilityExpenses.filter(e => e.categoryId === cat.id)
.reduce((s, e) => s + myResponsibilityAmount(e), 0);
return { ...cat, total };
}).filter(c => c.total > 0).sort((a, b) => b.total - a.total);
}, [myResponsibilityExpenses, categories, currentMember, expenses]);
const recentExpenses = useMemo(() => {
return [...myResponsibilityExpenses].sort((a, b) => parseLocalDate(b.date) - parseLocalDate(a.date)).slice(0, 6);
}, [myResponsibilityExpenses]);
const myCards = paymentMethods.filter(pm => pm.memberId === currentMember.id && pm.type === 'credit');
// Total gasto por cartão no mês corrente (valor cheio — igual à tela de Cartões)
const pmMonthTotals = useMemo(() => {
const totals = {};
expenses.forEach(e => {
if (!e.pmId) return;
const d = new Date((e.date || '').length === 10 ? e.date + 'T00:00:00' : e.date);
if (d.getFullYear() === currentMonth.year && d.getMonth() + 1 === currentMonth.month) {
totals[e.pmId] = (totals[e.pmId] || 0) + (e.value || 0);
}
});
return totals;
}, [expenses, currentMonth]);
const totalInvested = investments.filter(i => i.memberId === currentMember.id).reduce((s, i) => s + i.value, 0);
const myInvs = investments.filter(i => i.memberId === currentMember.id);
// Taxa mensal canônica (renda fixa pela taxa anual), ponderada pelo valor aplicado.
const avgRate = portfolioMonthlyRatePct(myInvs);
// ── Variação percentual real vs mês anterior ────────────────
// Usa o mesmo cálculo de receita do sixMonthProj (direto + projeção mensal + A Receber)
// para garantir simetria com o totalIncome do mês atual.
// Guard: se o mês anterior não tem NENHUM lançamento real (ex: primeiro mês de uso),
// retorna noData=true e não exibe percentual — evita projeção de dados fictícios.
const prevMonthTotals = useMemo(() => {
const pd = new Date(currentMonth.year, currentMonth.month - 2);
const pY = pd.getFullYear();
const pM = pd.getMonth() + 1;
// Guard — verifica se existem registros reais com data no mês anterior
const hasRealInc = incomes.some(i => {
const d = parseLocalDate(i.date);
return d.getFullYear() === pY && d.getMonth() + 1 === pM
&& i.memberId === currentMember.id
&& !String(i.id).startsWith('projected_');
});
const hasRealExp = expenses.some(e => {
const d = parseLocalDate(e.date);
return d.getFullYear() === pY && d.getMonth() + 1 === pM
&& (e.memberId === currentMember.id
|| (e.shared && e.sharedWith?.some(sw => sw.memberId === currentMember.id))
|| e.thirdPartyMemberId === currentMember.id);
});
if (!hasRealInc && !hasRealExp) return { prevExp: 0, prevInc: 0, noData: true };
// ── Despesas do mês anterior ──
const mExps = expenses.filter(e => {
const d = parseLocalDate(e.date);
if (d.getFullYear() !== pY || d.getMonth() + 1 !== pM) return false;
return e.memberId === currentMember.id
|| (e.shared && e.sharedWith?.some(sw => sw.memberId === currentMember.id))
|| e.thirdPartyMemberId === currentMember.id;
});
const prevExp = mExps.reduce((s, e) => {
if (e.shared && e.sharedWith?.length) {
const sw = e.sharedWith.find(x => x.memberId === currentMember.id);
return s + (sw ? e.value * sw.pct / 100 : e.value);
}
return s + (e.value || 0);
}, 0);
// ── Receitas: diretas + projeção de recorrentes (mesmo critério de sixMonthProj) ──
const direct = incomes.filter(i => {
if (isMemberReceivableIncome(i)) return false;
if (i.memberId !== currentMember.id) return false;
const d = parseLocalDate(i.date);
return d.getFullYear() === pY && d.getMonth() + 1 === pM;
});
const presentSigs = new Set(direct.map(i => `${(i.desc || '').trim().toLowerCase()}|${i.source}|${i.memberId}`));
const sigMap = {};
incomes.forEach(i => {
const d = parseLocalDate(i.date);
if (d.getFullYear() === pY && d.getMonth() + 1 === pM) return;
if (i.recurrence !== 'monthly' || i.memberId !== currentMember.id || isMemberReceivableIncome(i)) return;
const sig = `${(i.desc || '').trim().toLowerCase()}|${i.source}|${i.memberId}`;
if (presentSigs.has(sig)) return; // já existe como direto → não projeta
const prev = sigMap[sig] ? parseLocalDate(sigMap[sig].date) : null;
if (!prev || d > prev) sigMap[sig] = i;
});
const prevProprio = [...direct, ...Object.values(sigMap)].reduce((s, i) => s + (i.value || 0), 0);
// ── A Receber do mês anterior ──
const mySharedCrP = mExps.filter(e =>
e.memberId === currentMember.id && e.shared && e.sharedWith?.some(sw => sw.memberId !== currentMember.id)
);
const scIds = new Set(mySharedCrP.map(e => e.id));
const shItems = mySharedCrP.flatMap(exp =>
(exp.sharedWith||[]).filter(sw => sw.memberId !== currentMember.id)
.map(sw => ({ dId: sw.memberId, amt: (exp.value||0) * (sw.pct||0) / 100 }))
);
const shSigs = new Set(shItems.map(it => `${it.dId}|${it.amt.toFixed(2)}`));
const tpExtra = incomes.filter(i => {
if (!isMemberReceivableIncome(i) || i.memberId !== currentMember.id) return false;
const d = parseLocalDate(i.date);
return d.getFullYear() === pY && d.getMonth() + 1 === pM;
}).filter(i => {
if (i.source === 'member_operation' || i.operationId) return true;
if (i.relatedExpenseId && scIds.has(i.relatedExpenseId)) return false;
if (shSigs.has(`${i.fromMemberId}|${parseFloat(i.value).toFixed(2)}`)) return false;
return true;
});
const prevAReceber = shItems.reduce((s, it) => s + it.amt, 0) + tpExtra.reduce((s, i) => s + i.value, 0);
const prevInc = prevProprio + prevAReceber;
return { prevExp, prevInc, noData: false };
}, [expenses, incomes, currentMonth, currentMember]);
// Variação %: undefined se mês anterior não tem dados reais
const incomeTrend = (!prevMonthTotals.noData && prevMonthTotals.prevInc > 0)
? Math.round(((totalIncome - prevMonthTotals.prevInc) / prevMonthTotals.prevInc) * 1000) / 10
: undefined;
// Despesa: valor positivo = subiu, negativo = caiu (a cor é invertida via prop invertTrend no KpiCard)
const expenseTrend = (!prevMonthTotals.noData && prevMonthTotals.prevExp > 0)
? Math.round(((totalExpenses - prevMonthTotals.prevExp) / prevMonthTotals.prevExp) * 1000) / 10
: undefined;
// ── Totais de despesas pagas e pendentes — mesmo filtro do totalExpenses ──
const _memberMonthExps = useMemo(() => expenses.filter(e => {
const d = new Date((e.date || '').length === 10 ? e.date + 'T00:00:00' : e.date);
const inMonth = d.getFullYear() === currentMonth.year && d.getMonth() + 1 === currentMonth.month;
if (!inMonth) return false;
return expenseInvolvesMember(e, currentMember.id, paymentMethods);
}), [expenses, currentMonth, currentMember, paymentMethods]);
const paidExpensesTotal = useMemo(() =>
_memberMonthExps.filter(e => e.status === 'paid').reduce((s, e) => s + memberExpenseAmount(e, currentMember.id, paymentMethods), 0),
[_memberMonthExps, currentMember, paymentMethods]);
const pendingExpensesTotal = useMemo(() =>
_memberMonthExps.filter(e => e.status !== 'paid').reduce((s, e) => s + memberExpenseAmount(e, currentMember.id, paymentMethods), 0),
[_memberMonthExps, currentMember, paymentMethods]);
// Inclui todas as fontes: próprias, third_party e acertos (settlement)
const receivedIncomesTotal = useMemo(() =>
monthIncomes.filter(i => i.status === 'received').reduce((s, i) => s + (i.value || 0), 0),
[monthIncomes]);
const expectedIncomesTotal = useMemo(() =>
monthIncomes.filter(i => i.status === 'expected').reduce((s, i) => s + (i.value || 0), 0),
[monthIncomes]);
// ── Breakdown por tipo: mês atual + 2 próximos ───────────
const typeBreakdown = useMemo(() => {
return [-2, -1, 0, 1, 2].map(offset => {
const d = new Date(currentMonth.year, currentMonth.month - 1 + offset);
const yr = d.getFullYear(), mo = d.getMonth() + 1;
const label = ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez'][d.getMonth()] + '/' + String(yr).slice(-2);
const mExps = expenses.filter(e => {
const ed = new Date((e.date || '').length === 10 ? e.date + 'T00:00:00' : e.date);
if (ed.getFullYear() !== yr || ed.getMonth() + 1 !== mo) return false;
return expenseInvolvesMember(e, currentMember.id, paymentMethods);
});
const sum = (arr) => arr.reduce((s, e) => s + memberExpenseAmount(e, currentMember.id, paymentMethods), 0);
const fixed = mExps.filter(e => e.type === 'fixed');
const installment = mExps.filter(e => e.type === 'installment');
const single = mExps.filter(e => e.type === 'single' || !e.type);
return {
label, isCurrent: offset === 0, isPast: offset < 0, isFuture: offset > 0,
fixed: { count: fixed.length, total: sum(fixed) },
installment: { count: installment.length, total: sum(installment) },
single: { count: single.length, total: sum(single) },
total: sum(mExps),
};
});
}, [expenses, currentMonth, currentMember, paymentMethods]);
// ── Projeção 6 meses (despesas + receitas) ────────────────
const MONTHS_PT = ['Janeiro','Fevereiro','Março','Abril','Maio','Junho','Julho','Agosto','Setembro','Outubro','Novembro','Dezembro'];
const MONTHS_SHORT = ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez'];
const sixMonthProj = useMemo(() => {
return Array.from({ length: 6 }, (_, i) => {
const d = new Date(currentMonth.year, currentMonth.month - 1 + i);
const y = d.getFullYear();
const m = d.getMonth() + 1;
const label = `${MONTHS_SHORT[d.getMonth()]}/${String(y).slice(-2)}`;
const fullLabel = `${MONTHS_PT[d.getMonth()]} ${y}`;
// ── Despesas do membro neste mês (mesma regra única, valor cheio) ──
const mExps = expenses.filter(e => {
const ed = parseLocalDate(e.date);
if (ed.getFullYear() !== y || ed.getMonth() + 1 !== m) return false;
return expenseInvolvesMember(e, currentMember.id, paymentMethods);
});
const totalExp = mExps.reduce((s, e) => s + memberExpenseAmount(e, currentMember.id, paymentMethods), 0);
// ── Receitas próprias do membro + projeção de recorrentes ──
const direct = incomes.filter(i => {
if (isMemberReceivableIncome(i)) return false;
if (i.memberId !== currentMember.id) return false;
const id = parseLocalDate(i.date);
return id.getFullYear() === y && id.getMonth() + 1 === m;
});
const presentSigs = new Set(
direct.filter(i => !isMemberReceivableIncome(i))
.map(i => `${(i.desc || '').trim().toLowerCase()}|${i.source}|${i.memberId}`)
);
const sigMap = {};
incomes.forEach(i => {
const id = parseLocalDate(i.date);
if (id.getFullYear() === y && id.getMonth() + 1 === m) return;
if (i.recurrence !== 'monthly') return;
if (i.memberId !== currentMember.id) return;
if (isMemberReceivableIncome(i)) return;
const sig = `${(i.desc || '').trim().toLowerCase()}|${i.source}|${i.memberId}`;
if (presentSigs.has(sig)) return;
const prev = sigMap[sig] ? parseLocalDate(sigMap[sig].date) : null;
if (!prev || id > prev) sigMap[sig] = i;
});
const projectedIncs = Object.values(sigMap);
const totalProprio = [...direct, ...projectedIncs].reduce((s, i) => s + (i.value || 0), 0);
// ── A Receber: Reembolsos (receitas third_party) do mês — mesma base do
// totalAReceber do mês atual e do Balancete (uma linha por devedor).
const totalAReceber = incomes.filter(i => {
if (!isMemberReceivableIncome(i)) return false;
if (i.memberId !== currentMember.id) return false;
const id = parseLocalDate(i.date);
return id.getFullYear() === y && id.getMonth() + 1 === m;
}).reduce((s, i) => s + (Number(i.value) || 0), 0);
const totalInc = totalProprio + totalAReceber;
return { y, m, label, fullLabel, totalExp, totalInc, totalProprio, totalAReceber, balance: totalInc - totalExp, isCurrent: i === 0, mExps };
});
}, [expenses, incomes, currentMonth, currentMember, paymentMethods]);
// Pré-carrega os 2 meses anteriores ("Despesas por tipo" mostra −2..+2 — sem
// isso os meses passados apareciam zerados) e os próximos 5 (projeção).
useEffect(() => {
for (let i = -2; i <= 5; i++) {
if (i === 0) continue;
const d = new Date(currentMonth.year, currentMonth.month - 1 + i);
fetchMonthExpenses(d.getFullYear(), d.getMonth() + 1);
}
}, [currentMonth.year, currentMonth.month]);
// Upcoming installments (next 7 days from today)
const now = new Date();
const upcoming = expenses.filter(e => {
if (e.type !== 'installment') return false;
const d = parseLocalDate(e.date);
const diff = (d - now) / (1000 * 60 * 60 * 24);
return diff >= 0 && diff <= 7 && e.memberId === currentMember.id;
});
// Unread notifications for alert panel
const unreadNotifs = notifications.filter(n => !n.read).slice(0, 3);
// Mesma base que alimentava a rosca; muda apenas a apresentação.
const categoryTotal = catBreakdown.reduce((sum, cat) => sum + cat.total, 0);
const selectedCategory = catBreakdown.find(cat => cat.id === selectedCat);
const otherCategoriesTotal = catBreakdown.slice(4).reduce((sum, cat) => sum + cat.total, 0);
const categoryPercent = value => categoryTotal > 0 ? value / categoryTotal * 100 : 0;
const formatCategoryPercent = value => `${categoryPercent(value).toLocaleString('pt-BR', { minimumFractionDigits: 1, maximumFractionDigits: 1 })}%`;
const filteredExpenses = selectedCat
? recentExpenses.filter(e => e.categoryId === selectedCat)
: recentExpenses;
return (
{toast &&
setToast(null)} />}
setShowDiaDiaForm(false)} title="Lançar Dia a Dia">
setShowDiaDiaForm(false)}
onSave={exp => { addExpense(exp); setToast('Gasto lançado no Dia a Dia!'); }}
/>
{/* Edit dia-a-dia modal */}
{editingDiaDia && (
setEditingDiaDia(null)} title="Editar Gasto Dia a Dia">
setEditingDiaDia(null)}
onSave={(updates) => {
updateExpense(editingDiaDia.id, updates);
setEditingDiaDia(null);
setToast('Gasto atualizado!');
}}
onDelete={() => {
deleteExpense(editingDiaDia.id);
setEditingDiaDia(null);
setToast('Gasto removido.');
}}
/>
)}
{/* ── KPI Linha 1: Visão financeira principal ── */}
= 0 ? 'var(--accent)' : '#EF4444'} sub={balance >= 0 ? 'Positivo 🟢' : 'Negativo 🔴'} />
{/* ── KPI Linha 2: Status de pagamentos ── */}
Receitas Recebidas
{fmtBRL(receivedIncomesTotal)}
0 ? "var(--warning)" : "var(--success)"), fontWeight: 600 }}>
{expectedIncomesTotal > 0 ? `${fmtBRL(expectedIncomesTotal)} previsto` : '✓ Todas recebidas'}
{totalIncome > 0 && (
{(receivedIncomesTotal / totalIncome * 100).toFixed(0)}% do total
)}
Despesas Pagas
{fmtBRL(paidExpensesTotal)}
0 ? "var(--warning)" : "var(--success)"), fontWeight: 600 }}>
{pendingExpensesTotal > 0 ? `${fmtBRL(pendingExpensesTotal)} pendente` : totalExpenses > 0 ? '✓ Todas pagas' : '—'}
{totalExpenses > 0 && (
{(paidExpensesTotal / totalExpenses * 100).toFixed(0)}% do total
)}
{/* Middle row: Dia a Dia + Categorias + Balancete */}
{/* Dia a Dia */}
Dia a Dia — {currentMember.name.split(' ')[0]}
{fmtBRL(diaDiaUsed)}
de {fmtBRL(diaDiaLimit)} / mês
= 90 ? "var(--danger)" : diaDiaPct >= 80 ? "var(--warning)" : "var(--success)"), letterSpacing: '-0.03em' }}>
{diaDiaPct.toFixed(0)}%
utilizado
Restante: {fmtBRL(Math.max(0, diaDiaLimit - diaDiaUsed))}
{fmtBRL(diaDiaLimit)} reservado no total do mês
{diaDiaOverPace && (
Ritmo acima do planejado — meta é {fmtBRL(diaDiaDailyTarget)}/dia. Pra fechar o mês dentro do limite, gaste até {fmtBRL(diaDiaTargetFromNow)}/dia a partir de agora.
)}
setShowDiaDiaForm(true)}
style={{ width: '100%', marginTop: 14, padding: '9px', background: "var(--action-bg)", border: 'none', borderRadius: 9, color: 'white', fontWeight: 700, cursor: 'pointer', fontSize: 13, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, transition: 'opacity 150ms' }}
onMouseEnter={e => e.currentTarget.style.opacity = '0.88'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
Lançar gasto rápido
{/* Lista dos últimos dia-a-dia com edição */}
{diaDiaExpenses && diaDiaExpenses.slice(0, 4).map(exp => (
{exp.desc}
{parseLocalDate(exp.date).toLocaleDateString('pt-BR')}
{fmtBRL(exp.value)}
setEditingDiaDia(exp)} style={{ background: 'var(--surface-3)', border: '1px solid var(--border)', borderRadius: 6, padding: '3px 6px', cursor: 'pointer', color: 'var(--text-2)', display: 'flex', alignItems: 'center' }}>
))}
{/* Categorias: barras compactas, com a mesma responsabilidade e filtro. */}
Por Categoria — sua responsabilidade
Sua parte nas despesas, mesmo lançadas por outra pessoa.
{selectedCategory ? selectedCategory.name : 'Total gastos'}
{fmtBRL(selectedCategory ? selectedCategory.total : categoryTotal)}
{catBreakdown.length === 0 ? (
Nenhuma despesa sob sua responsabilidade neste mês.
) : (
{(showAllCategories ? catBreakdown : catBreakdown.slice(0, 4)).map(cat => (
setSelectedCat(selectedCat === cat.id ? null : cat.id)}
style={{ '--category-color': cat.color }}>
{cat.name}
{fmtBRL(cat.total)}
{formatCategoryPercent(cat.total)}
))}
)}
{catBreakdown.length > 4 && (
setShowAllCategories(value => !value)}>
{showAllCategories ? 'Mostrar menos' : `Demais categorias · ${formatCategoryPercent(otherCategoriesTotal)}`}
{!showAllCategories && fmtBRL(otherCategoriesTotal)}
)}
{selectedCategory && (
setSelectedCat(null)}>Limpar filtro de categoria
)}
{/* Balancete Familiar — dashboard: só totais, detalhe no modal completo */}
Balancete Familiar
{balancete.length > 0 && (
{balancete.length} {balancete.length === 1 ? 'pendência' : 'pendências'}
)}
{balancete.length === 0 ? (
✅
Nenhum saldo pendente
Todos em dia!
) : (
{balancete.map((item, i) => {
const fromM = members.find(m => m.id === item.from);
const toM = members.find(m => m.id === item.to);
if (!fromM || !toM) return null;
return (
{fromM.initials}
{toM.initials}
{fromM.name.split(' ')[0]} deve a {toM.name.split(' ')[0]}
{fmtBRL(item.amount)}
);
})}
)}
Ver detalhes do Balancete
{/* Minhas Dívidas — só aparece quando o membro tem despesas divididas a seu cargo */}
{mySharedDebts && mySharedDebts.length > 0 && (
Aguardando sua aprovação
{(() => {
// Agrupa parcelas/ocorrências da MESMA série num único item (aprovar de uma vez) —
// mesma lógica usada em Despesas, cobre tanto cota dividida quanto responsabilidade
// integral (shared:false) atribuída sem divisão. Ver lib/finance-calc.js.
const isAccepted = (id) => sharedStatus[`${id}:${currentMember.id}`] === 'accepted';
const srcAll = (balanceteExpenses && balanceteExpenses.length ? balanceteExpenses : expenses);
const groups = buildPendingApprovalGroups(mySharedDebts, srcAll, currentMember.id, sharedStatus);
return groups.map(g => {
const exp = g.rep;
const myPct = exp.shared ? (exp.sharedWith?.find(sw => sw.memberId === currentMember.id)?.pct || 0) : 100;
const isFullResp = myPct === 100;
const isSeries = g.items.length > 1;
const myAmount = g.items.reduce((s, it) => {
const pct = it.shared ? (it.sharedWith?.find(sw => sw.memberId === currentMember.id)?.pct || 0) : 100;
return s + (it.value * pct / 100);
}, 0);
const accepted = isAccepted(exp.id);
const launcher = members.find(m => m.id === exp.memberId);
const pm = paymentMethods.find(p => p.id === exp.pmId);
return (
{exp.desc}
{isSeries && {g.items.length}x parcelas }
{launcher && {isFullResp ? 'Atribuído por' : 'Lançado por'} {launcher.name.split(' ')[0]} }
{pm && · {pm.name} }
{isSeries
? · {g.items.length} parcelas
: · {new Date(exp.date + 'T00:00:00').toLocaleDateString('pt-BR')} }
{!isFullResp && · {myPct}% }
{fmtBRL(myAmount)}
{isSeries ? 'total' : (isFullResp ? 'total' : `${myPct}%`)}
{accepted ? (
Aceito
) : (
{ if (!accepted) acceptSharedExpenses(g.items, currentMember.id); }}
style={{ marginTop: 6, padding: '5px 14px', background: "var(--success-action)", border: 'none', borderRadius: 7, color: 'white', fontWeight: 700, cursor: 'pointer', fontSize: 12 }}
>
{isSeries ? 'Confirmar todas' : (isFullResp ? 'Aceitar' : 'Confirmar')}
)}
);
});
})()}
)}
{/* ── Visão Futura (subiu) ─────────────────────────────── */}
{/* Próximo Mês + Gráfico 6 meses */}
{(() => {
const nm = sixMonthProj[1];
if (!nm) return null;
const topExps = [...(nm.mExps || [])]
.sort((a, b) => memberExpenseAmount(b, currentMember.id, paymentMethods) - memberExpenseAmount(a, currentMember.id, paymentMethods))
.slice(0, 5);
const catTotals = displayCategories.map(c => ({
...c, total: (nm.mExps || []).filter(e => e.categoryId === c.id).reduce((s, e) => s + memberExpenseAmount(e, currentMember.id, paymentMethods), 0)
})).filter(c => c.total > 0).sort((a, b) => b.total - a.total).slice(0, 3);
return (
Despesas — Próximo Mês
{nm.fullLabel}
{fmtBRL(nm.totalExp)}
0 ? 8 : 14 }}>
{nm.mExps.length} despesa{nm.mExps.length !== 1 ? 's' : ''} prevista{nm.mExps.length !== 1 ? 's' : ''}
{nm.totalInc > 0 && · receita: {fmtBRL(nm.totalInc)} }
{nm.totalInc > 0 && (
{nm.totalProprio > 0 && 💰 {fmtBRL(nm.totalProprio)} }
{nm.totalAReceber > 0 && 👥 +{fmtBRL(nm.totalAReceber)} }
)}
{catTotals.length > 0 && (
{catTotals.map(c => {c.name} {fmtBRL(c.total)} )}
)}
{topExps.length === 0 ? (
Nenhuma despesa encontrada.
) : (
{topExps.map((e, idx) => {
const cat = displayCategories.find(c => c.id === e.categoryId);
const dispVal = memberExpenseAmount(e, currentMember.id, paymentMethods);
return (
{e.desc}
{cat?.name}{e.type === 'fixed' && ' · Fixa'}{e.type === 'installment' && ` · ${e.currentInstallment}/${e.installments}x`}
{fmtBRL(dispVal)}
);
})}
)}
);
})()}
Receita Prevista × Despesa Prevista — Próximos 6 Meses
{(() => {
const data = sixMonthProj;
const maxVal = Math.max(...data.flatMap(d => [d.totalInc, d.totalExp, 1]));
const mag = Math.pow(10, Math.floor(Math.log10(maxVal)));
const yMax = Math.ceil(maxVal * 1.15 / mag) * mag;
const W = 480, H = 200;
const PAD = { top: 16, right: 16, bottom: 46, left: 62 };
const cW = W - PAD.left - PAD.right; const cH = H - PAD.top - PAD.bottom;
const nTicks = 4;
const ticks = Array.from({ length: nTicks + 1 }, (_, i) => (yMax / nTicks) * i);
const yS = v => cH - (v / yMax) * cH;
const monthW = cW / data.length; const bW = Math.min(monthW * 0.33, 22); const gap = 3;
const fmtK = v => v >= 1000000 ? `${(v/1000000).toFixed(1)}M` : v >= 1000 ? `${(v/1000).toFixed(0)}k` : v.toFixed(0);
return (
{ticks.map((t, i) => { const y = PAD.top + yS(t); return (R${fmtK(t)} ); })}
{data.map((d, i) => {
const cx = PAD.left + i * monthW + monthW / 2;
const incH = Math.max((d.totalInc / yMax) * cH, d.totalInc > 0 ? 2 : 0);
const expH = Math.max((d.totalExp / yMax) * cH, d.totalExp > 0 ? 2 : 0);
const baseY = PAD.top + cH; const alpha = d.isCurrent ? 'FF' : 'AA';
return (
{d.isCurrent && }
{d.label}
{incH > 20 && {fmtK(d.totalInc)} }
{expH > 20 && {fmtK(d.totalExp)} }
);
})}
Receita prevista
Despesa prevista
Mês atual destacado
);
})()}
{/* ── Saldo Projetado — Próximos 6 Meses (BLOCOS) ──────── */}
Saldo Projetado — Próximos 6 Meses
{sixMonthProj.map((row, i) => {
const saldoPos = row.balance >= 0;
const saldoColor = saldoPos ? 'var(--success)' : 'var(--danger)';
const noData = !row.totalInc && !row.totalExp && !row.isCurrent;
const statusLabel = row.balance > 0 ? '✓ Superávit' : row.balance < 0 ? '⚠ Déficit' : '= Neutro';
return (
{row.isCurrent && ATUAL }
{row.fullLabel}
{!noData &&
{statusLabel} }
{noData ? (
sem dados
) : (
<>
Receita
{fmtBRL(row.totalInc)}
{row.totalAReceber > 0 &&
👥 +{fmtBRL(row.totalAReceber)}
}
Despesa
{fmtBRL(row.totalExp)}
Saldo
{row.balance >= 0 ? '+' : ''}{fmtBRL(row.balance)}
>
)}
);
})}
{(() => {
const tInc = sixMonthProj.reduce((s, r) => s + r.totalInc, 0);
const tExp = sixMonthProj.reduce((s, r) => s + r.totalExp, 0);
const tBal = sixMonthProj.reduce((s, r) => s + r.balance, 0);
const pos = tBal >= 0; const c = pos ? 'var(--success)' : 'var(--danger)';
return (
Total 6 meses
Receita {fmtBRL(tInc)}
Despesa {fmtBRL(tExp)}
Saldo {pos ? '+' : ''}{fmtBRL(tBal)}
{pos ? '✓ Superávit' : '⚠ Déficit'}
);
})()}
{/* ── Meios de Pagamento — LARGURA TOTAL ───────────────── */}
Meios de Pagamento
onNavigate('cartoes')} style={{ fontSize: 11, color: 'var(--accent)', background: 'none', border: 'none', cursor: 'pointer', fontWeight: 600 }}>Ver tudo →
{myCards.length === 0 ? (
) : (
{myCards.map(pm => {
const monthUsed = pmMonthTotals[pm.id] || 0;
const usedPct = pm.limit ? (monthUsed / pm.limit) * 100 : 0;
const daysUntilDue = pm.dueDay ? pm.dueDay - new Date().getDate() : null;
// Só alerta de vencimento se ainda há despesas pendentes nesse cartão no mês
const hasPending = expenses.some(e => {
if (e.pmId !== pm.id || e.status === 'paid' || e.isProjected) return false;
const d = new Date((e.date || '').length === 10 ? e.date + 'T00:00:00' : e.date);
return d.getFullYear() === currentMonth.year && d.getMonth() + 1 === currentMonth.month;
});
const nearDue = hasPending && daysUntilDue !== null && daysUntilDue >= 0 && daysUntilDue <= 3;
const color = pm.color || '#0D9488';
return (
onNavigate('cartoes')} style={{ padding: 14, background: nearDue ? "var(--danger-bg)" : 'var(--surface-1)', borderRadius: 14, border: `1.5px solid ${nearDue ? '#EF444433' : color + '33'}`, cursor: 'pointer', transition: 'transform 150ms', position: 'relative', overflow: 'hidden' }}
onMouseEnter={e => e.currentTarget.style.transform = 'translateY(-2px)'}
onMouseLeave={e => e.currentTarget.style.transform = 'translateY(0)'}
>
{pm.name}
{pm.bank || pm.type}
{nearDue &&
⚠ {daysUntilDue}d }
{pm.dueDay && !nearDue &&
dia {pm.dueDay} }
0 ? 'var(--text-1)' : 'var(--text-3)'), letterSpacing: '-0.02em', marginBottom: 4 }}>{fmtBRL(monthUsed)}
{pm.limit ? (
<>
= 80 ? "var(--danger)" : 'var(--text-3)') }}>{usedPct.toFixed(0)}% usado
Limite: {fmtBRL(pm.limit)}
>
) : (
{pm.type}
)}
);
})}
)}
{/* Investimentos resumo abaixo dos cartões */}
Investimentos
{fmtBRL(totalInvested)}
+{avgRate.toFixed(2)}%
a.m. médio
onNavigate('investimentos')} style={{ padding: '8px 16px', background: 'var(--success-bg)', border: 'none', borderRadius: 8, color: "var(--success)", fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>
Ver investimentos →
{/* ── Bottom: Despesas por Tipo (esq) + Despesas Recentes (dir) ── */}
{/* ── Despesas por Tipo — 5 meses (ESQUERDA) ──────────── */}
{/* Título */}
Despesas por Tipo
{/* Blocos por mês — flui sem scroll horizontal */}
{typeBreakdown.map(tb => {
const empty = tb.total === 0;
return (
{tb.label}
{tb.isCurrent && }
{[
{ key: 'fixed', label: 'Fixas', icon: 'Lock', color: '#3B82F6' },
{ key: 'installment', label: 'Parceladas', icon: 'Layers', color: '#8B5CF6' },
{ key: 'single', label: 'Únicas', icon: 'Zap', color: '#F59E0B' },
].map(({ key, label, icon, color }) => (
{label}
0 ? 'var(--text-1)' : 'var(--text-3)'), whiteSpace: 'nowrap' }}>
{tb[key].total > 0 ? fmtBRL(tb[key].total) : '—'}
))}
Total
{fmtBRL(tb.total)}
);
})}
{/* ── Despesas Recentes (DIREITA) ───────────────────────── */}
Despesas Recentes — sua responsabilidade
Divididas ou atribuídas a você; veja "Meios de Pagamento" pra tudo que passa pelo seu cartão
{selectedCat &&
setSelectedCat(null)} style={{ fontSize: 11, color: 'var(--accent)', background: 'none', border: 'none', cursor: 'pointer', flexShrink: 0 }}>Limpar × }
{filteredExpenses.length === 0 &&
}
{filteredExpenses.map(e => {
const cat = displayCategories.find(c => c.id === e.categoryId);
const myAmount = myResponsibilityAmount(e);
const isPartial = Math.abs(myAmount - e.value) > 0.01;
return (
{e.desc}
{e.blocked && }
{cat?.name}{e.type === 'installment' ? ` • ${e.currentInstallment}/${e.installments}x` : ''}{e.shared ? ' • Compartilhado' : ''}
{fmtBRL(myAmount)}
{isPartial &&
sua parte de {fmtBRL(e.value)}
}
);
})}
onNavigate('despesas')} style={{ width: '100%', marginTop: 12, padding: '8px', background: 'var(--surface-2)', border: 'none', borderRadius: 8, color: 'var(--text-2)', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>
Ver todas as despesas →
);
}
Object.assign(window, { Dashboard });