// ============================================================ // 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 (
Lançamento rápido — marcado como Dia a Dia
{ setDesc(e.target.value); setError(''); }} placeholder="Ex: Lanche, Uber, Farmácia…" autoFocus />
{ setValue(e.target.value); setError(''); }} placeholder="0,00" onKeyDown={e => e.key === 'Enter' && handleSave()} /> setDate(e.target.value)} />
); } 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 (
{ setDesc(e.target.value); setError(''); }} />
setValue(e.target.value)} />
{myPMs.length > 0 && ( )}
); } 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.
)}
Últimos 3 meses
{/* 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)}
))}
{/* 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 => ( ))}
)} {catBreakdown.length > 4 && ( )} {selectedCategory && ( )}
{/* 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)}
); })}
)}
{/* 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
) : ( )}
); }); })()}
)} {/* ── Visão Futura (subiu) ─────────────────────────────── */}
Visão Futura
{/* 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
{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
{/* ── 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 && }
{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)}
}
); })}
); } Object.assign(window, { Dashboard });