// ============================================================ // FINAFAMILY — Meios de Pagamento Screen // Seletor de mês, totais calculados, histórico de faturas // ============================================================ const { useState, useMemo, useEffect, useRef } = React; function Cartoes() { const { paymentMethods, members, expenses, currentMember, categories, invoiceHistory, exportToCSV, currentMonth, setCurrentMonth, fetchMonthExpenses, updateExpense, deleteExpense, deleteExpenseSeries, markExpensePaid, fetchInvoicePreview, payInvoice, reopenInvoice, } = useApp(); const [selectedPm, setSelectedPm] = useState(null); const [toast, setToast] = useState(null); const [editingExpense, setEditingExpense] = useState(null); const [invoiceDialog, setInvoiceDialog] = useState(null); const [invoicePreview, setInvoicePreview] = useState(null); const [invoiceLoading, setInvoiceLoading] = useState(false); const [invoiceError, setInvoiceError] = useState(''); const [invoiceRetry, setInvoiceRetry] = useState(0); const [invoiceAction, setInvoiceAction] = useState(null); const [reopenReason, setReopenReason] = useState(''); const invoiceActionKey = useRef(null); const invoiceDialogRef = useRef(null); const invoiceActionRef = useRef(null); // Mês local — começa no mês global mas pode ser navegado independentemente const [viewMonth, setViewMonth] = useState(() => ({ ...currentMonth })); const monthNames = ['Janeiro','Fevereiro','Março','Abril','Maio','Junho', 'Julho','Agosto','Setembro','Outubro','Novembro','Dezembro']; // Carrega despesas do mês em análise sempre que mudar useEffect(() => { fetchMonthExpenses(viewMonth.year, viewMonth.month); }, [viewMonth.year, viewMonth.month]); useEffect(() => { if (!invoiceDialog) return; let active = true; setInvoiceLoading(true); setInvoiceError(''); setInvoicePreview(null); fetchInvoicePreview(invoiceDialog.id, viewMonth.month, viewMonth.year) .then(preview => { if (active) setInvoicePreview(preview); }) .catch(err => { if (active) setInvoiceError(err?.message || 'Não foi possível carregar a fatura.'); }) .finally(() => { if (active) setInvoiceLoading(false); }); return () => { active = false; }; }, [invoiceDialog?.id, viewMonth.month, viewMonth.year, invoiceRetry]); useEffect(() => { invoiceActionRef.current = invoiceAction; }, [invoiceAction]); useEffect(() => { if (!invoiceDialog) return; const previouslyFocused = document.activeElement; const dialog = invoiceDialogRef.current; dialog?.focus(); function onKeyDown(event) { if (event.key === 'Escape' && !invoiceActionRef.current) { setInvoiceDialog(null); return; } if (event.key !== 'Tab' || !dialog) return; const focusable = [...dialog.querySelectorAll('button:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')]; if (!focusable.length) return; const first = focusable[0], last = focusable[focusable.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } } document.addEventListener('keydown', onKeyDown); return () => { document.removeEventListener('keydown', onKeyDown); previouslyFocused?.focus?.(); }; }, [invoiceDialog?.id]); function prevMonth() { setViewMonth(m => m.month === 1 ? { year: m.year - 1, month: 12 } : { ...m, month: m.month - 1 }); } function nextMonth() { setViewMonth(m => m.month === 12 ? { year: m.year + 1, month: 1 } : { ...m, month: m.month + 1 }); } const allCards = paymentMethods.filter(pm => pm.memberId === currentMember.id); const creditCards = allCards.filter(pm => pm.type === 'credit'); const accounts = allCards.filter(pm => pm.type !== 'credit'); // Quanto de uma despesa ainda falta ser reembolsado ao DONO do cartão — // fonte única (expenseResponsibilityRows) que cobre ao mesmo tempo divisão, // responsabilidade integral transferida (ex: "CARRO" 100% de outro membro, // sem ser dividida) e uso do cartão por terceiro (responsável default é // quem lançou, que é justamente quem deve reembolsar o dono do cartão). function receivableFromExpense(exp, pmOwnerId) { return expenseResponsibilityRows(exp) .filter(row => row.responsibleMemberId !== pmOwnerId && row.responsibleMemberId !== UNASSIGNED_RESPONSIBLE_ID) .reduce((s, row) => s + row.amountCents, 0) / 100; } // Despesas do mês selecionado const monthAllExpenses = useMemo(() => expenses.filter(e => { const d = new Date((e.date || '').length === 10 ? e.date + 'T00:00:00' : e.date); return d.getFullYear() === viewMonth.year && d.getMonth() + 1 === viewMonth.month; }), [expenses, viewMonth]); // Total cobrado no cartão (fatura cheia — valor cheio de todas as despesas) const pmMonthTotals = useMemo(() => { const totals = {}; monthAllExpenses.forEach(e => { if (!e.pmId) return; totals[e.pmId] = (totals[e.pmId] || 0) + (e.value || 0); }); return totals; }, [monthAllExpenses]); // Valor a receber por cartão — soma, por qualquer motivo (divisão, responsabilidade // integral transferida, ou uso do cartão por terceiro), o que não é do próprio dono. const pmMonthReceivable = useMemo(() => { const rec = {}; monthAllExpenses.forEach(e => { if (!e.pmId) return; const pmOwnerId = paymentMethods.find(pm => pm.id === e.pmId)?.memberId; if (!pmOwnerId) return; const owed = receivableFromExpense(e, pmOwnerId); if (owed > 0) rec[e.pmId] = (rec[e.pmId] || 0) + owed; }); return rec; }, [monthAllExpenses, paymentMethods]); // Despesas do cartão selecionado no mês const cardExpenses = useMemo(() => { if (!selectedPm) return []; return monthAllExpenses .filter(e => e.pmId === selectedPm.id) .sort((a, b) => parseLocalDate(b.date) - parseLocalDate(a.date)); }, [selectedPm, monthAllExpenses]); const history = selectedPm && invoiceHistory ? (invoiceHistory[selectedPm.id] || []) : []; const maxHistory = history.length > 0 ? Math.max(...history.map(h => h.value)) : 1; // Total geral do mês — soma de todos os meios de pagamento (valor cheio) // Número grande do cabeçalho: fonte única de verdade (mesma regra de Despesas/Dashboard) const totalMonth = monthExpensesTotal(expenses, currentMember.id, paymentMethods, viewMonth.year, viewMonth.month); // Mantido apenas para meios sem ciclo de fatura (Pix, débito, conta e dinheiro). function getPendingForPm(pmId) { return monthAllExpenses.filter(e => e.pmId === pmId && e.status !== 'paid'); } function openInvoice(pm, event) { event?.stopPropagation?.(); invoiceActionKey.current = null; setReopenReason(''); setInvoiceError(''); setInvoiceDialog(pm); } function stableActionKey(action) { const signature = `${action}:${invoiceDialog.id}:${viewMonth.year}-${viewMonth.month}`; if (invoiceActionKey.current?.signature !== signature) { invoiceActionKey.current = { signature, value: globalThis.crypto?.randomUUID?.() || `${signature}:${Date.now()}`, }; } return invoiceActionKey.current.value; } async function handleInvoiceAction(action) { if (!invoiceDialog || !invoicePreview || invoiceAction) return; if (action === 'reopen' && reopenReason.trim().length < 3) { setInvoiceError('Informe o motivo da reabertura com pelo menos 3 caracteres.'); return; } setInvoiceAction(action); setInvoiceError(''); try { const key = stableActionKey(action); const updated = action === 'pay' ? await payInvoice(invoiceDialog.id, viewMonth.month, viewMonth.year, invoicePreview.fingerprint, key) : await reopenInvoice(invoiceDialog.id, viewMonth.month, viewMonth.year, reopenReason.trim(), key); setInvoicePreview(updated); invoiceActionKey.current = null; setInvoiceDialog(null); setToast(action === 'pay' ? 'Fatura paga com sucesso!' : 'Fatura reaberta para correção.'); setTimeout(() => setToast(null), 3000); } catch (err) { setInvoiceError(err?.message || `Não foi possível ${action === 'pay' ? 'pagar' : 'reabrir'} a fatura.`); } finally { setInvoiceAction(null); } } function handleExportCSV() { if (!selectedPm) return; const data = cardExpenses.map(e => ({ data: parseLocalDate(e.date).toLocaleDateString('pt-BR'), descricao: e.desc, categoria: categories.find(c => c.id === e.categoryId)?.name || '', valor: e.value.toFixed(2), status: e.status, tipo: e.type, compartilhado: e.shared ? 'Sim' : 'Não', })); exportToCSV(data, `fatura-${selectedPm.name.replace(/\s+/g, '-').toLowerCase()}-${viewMonth.month}-${viewMonth.year}.csv`); setToast('CSV exportado!'); setTimeout(() => setToast(null), 2000); } return (
{toast && setToast(null)} />} {/* Seletor de mês */}
{fmtBRL(totalMonth)}
Total gasto em {monthNames[viewMonth.month - 1]} {viewMonth.year}
{monthNames[viewMonth.month - 1]} {viewMonth.year}
{/* Lista de meios de pagamento */}
{/* Cartões de crédito — 2 colunas */} {(creditCards.length > 0 || true) && (
Cartões de Crédito
{creditCards.length === 0 ? ( ) : (
{creditCards.map(pm => { const monthUsed = pmMonthTotals[pm.id] || 0; // Limite comprometido = tudo que ainda não foi pago no cartão, // inclusive parcelas futuras (vem do servidor). As compras do // mês sozinhas subestimavam o uso e superestimavam o disponível. const limitUsed = pm.openBalanceCents != null ? pm.openBalanceCents / 100 : monthUsed; const usedPct = pm.limit ? (limitUsed / pm.limit) * 100 : 0; const isSelected = selectedPm?.id === pm.id; const pmHistory = invoiceHistory?.[pm.id] || []; const color = pm.color || '#0D9488'; return (
setSelectedPm(isSelected ? null : pm)} style={{ borderRadius: 18, overflow: 'hidden', cursor: 'pointer', boxShadow: isSelected ? `0 8px 32px ${color}55, 0 0 0 2px ${color}` : '0 4px 16px rgba(0,0,0,0.18)', transition: 'box-shadow 200ms, transform 150ms', transform: isSelected ? 'translateY(-3px)' : 'translateY(0)', }}> {/* ── Face do cartão de crédito ── */}
{/* Círculos decorativos */}
{/* Linha topo: banco + ícone */}
{pm.name}
{pm.bank &&
{pm.bank.toUpperCase()}
}
{/* Chip */}
{[0,1,2,3].map(i =>
)}
{/* Número simulado */}
•••• •••• •••• ••••
{/* Linha rodapé: gasto + fechamento */}
Lançamentos em {monthNames[viewMonth.month - 1]}
{fmtBRL(monthUsed)}
{pm.closingDay && pm.dueDay && (
Fecha / Vence
dia {pm.closingDay} / dia {pm.dueDay}
)}
{/* ── Rodapé com stats ── */}
{pm.limit ? ( <>
Limite: {fmtBRL(pm.limit)} = 80 ? "var(--danger)" : usedPct >= 60 ? "var(--warning)" : "var(--success)") }}> {usedPct.toFixed(0)}% utilizado
Disponível: {fmtBRL(Math.max(0, pm.limit - limitUsed))} {pm.openBalanceCents != null && · em aberto {fmtBRL(limitUsed)}}
) : (
Sem limite definido
)} {pmHistory.length > 0 && (
{pmHistory.map((h, i) => { const maxH = Math.max(...pmHistory.map(x => x.value)); const ht = Math.max(3, Math.round((h.value / maxH) * 14)); return (
); })}
)}
); })}
)}
)} {/* Contas / Pix / Outros — também em grid */} {accounts.length > 0 && (
Contas e Outros
{accounts.map(pm => { const monthUsed = pmMonthTotals[pm.id] || 0; const isSelected = selectedPm?.id === pm.id; const color = pm.color || 'var(--accent)'; const TYPE_ICON = { pix: 'Zap', cash: 'Banknote', debit: 'CreditCard', account: 'Building2' }; const TYPE_LABEL = { pix: 'Pix', cash: 'Dinheiro', debit: 'Débito', account: 'Conta Corrente' }; return (
setSelectedPm(isSelected ? null : pm)} style={{ cursor: 'pointer', borderRadius: 16, overflow: 'hidden', boxShadow: isSelected ? `0 0 0 2px ${color}, 0 4px 16px ${color}33` : '0 2px 8px rgba(0,0,0,0.12)', transition: 'box-shadow 200ms, transform 150ms', transform: isSelected ? 'translateY(-2px)' : 'translateY(0)', }}>
Gasto no mês
{fmtBRL(monthUsed)}
{pm.name}
{pm.bank ? `${pm.bank} · ` : ''}{TYPE_LABEL[pm.type] || pm.type}
{/* Contas sem fatura mantêm ajuste individual no painel de detalhe. */} {(() => { const pending = getPendingForPm(pm.id); if (!pending.length) return (
Tudo pago
); return (
{pending.length} pendente{pending.length !== 1 ? 's' : ''} · abra para ajustar
); })()}
); })}
)} {/* Resumo do mês por meio de pagamento */} {allCards.length > 1 && (
Resumo — {monthNames[viewMonth.month - 1]}
{allCards.map(pm => { const used = pmMonthTotals[pm.id] || 0; if (!used) return null; const pct = totalMonth > 0 ? (used / totalMonth) * 100 : 0; return (
{pm.name} {fmtBRL(used)}
); })}
Total do mês {fmtBRL(totalMonth)}
)}
{/* Painel de detalhe do cartão selecionado */} {selectedPm && selectedPm.type !== 'credit' && (
{/* Histórico de faturas */} {history.length > 0 && (
Histórico — últimas {history.length} faturas
{history.map((h, i) => { const ht = Math.round((h.value / maxHistory) * 68) + 4; const isLast = i === history.length - 1; return (
{isLast && (
{fmtBRL(h.value)}
)}
{h.month}
); })}
{history.map((h, i) => (
{h.month}
{fmtBRL(h.value)} {h.status === 'paid' ? 'Paga' : 'Pendente'}
))}
Média {fmtBRL(history.reduce((s, h) => s + h.value, 0) / history.length)}
)} {/* Lançamentos do mês */}
{selectedPm.name} — {monthNames[viewMonth.month - 1]}
{history.length === 0 && (
)}
{/* Totais do mês */} {(() => { const total = pmMonthTotals[selectedPm.id] || 0; const receivable = pmMonthReceivable[selectedPm.id] || 0; const myNet = total - receivable; return (
{/* Linha principal: Fatura total */}
0 ? 10 : 0 }}>
Total lançado
{fmtBRL(total)}
{selectedPm.limit && (
Disponível
{fmtBRL(Math.max(0, selectedPm.limit - (selectedPm.openBalanceCents != null ? selectedPm.openBalanceCents / 100 : total)))}
)}
{/* Linha de reembolsos (só aparece se houver valor a receber) */} {receivable > 0 && (
↩️ A receber
{fmtBRL(receivable)}
reembolsos esperados
Minha responsabilidade
{fmtBRL(Math.max(0, myNet))}
após reembolsos
)}
); })()} {/* Meios sem fatura continuam com ajuste individual por lançamento. */} {(() => { const pending = getPendingForPm(selectedPm.id); const paidCount = cardExpenses.filter(e => e.status === 'paid').length; if (!pending.length && cardExpenses.length > 0) return (
Todas as {paidCount} despesa{paidCount !== 1 ? 's' : ''} desta fatura já foram pagas
); if (!pending.length) return null; return (
{pending.length} lançamento{pending.length !== 1 ? 's' : ''} pendente{pending.length !== 1 ? 's' : ''}. Ajuste cada item abaixo.
); })()} {cardExpenses.length === 0 && ( )} {cardExpenses.map((e, idx) => { const cat = categories.find(c => c.id === e.categoryId); const pmOwnerId = selectedPm.memberId; const payerMember = e.thirdPartyMemberId && e.thirdPartyMemberId === pmOwnerId ? members.find(m => m.id === e.memberId) : e.memberId === pmOwnerId ? null : members.find(m => m.id === e.memberId); // owedToMe cobre os 3 motivos de reembolso ao dono do cartão numa // fonte só: divisão, responsabilidade integral transferida, e uso // do cartão por terceiro (ver receivableFromExpense). const owedToMe = receivableFromExpense(e, pmOwnerId); const isToReceive = owedToMe > 0.01 && Math.abs(owedToMe - e.value) < 0.01; const sharedReceivable = owedToMe > 0.01 && !isToReceive ? owedToMe : 0; const isPaid = e.status === 'paid'; const launcher = members.find(m => m.id === e.memberId); return (
{/* Ícone categoria */}
{/* Info */}
{e.desc} {e.shared && ÷} {isToReceive && ↩ reembolso} {isPaid && ✓ paga}
{new Date((e.date||'').length===10 ? e.date+'T00:00:00' : e.date).toLocaleDateString('pt-BR')} • {cat?.name} {payerMember && ( por {payerMember.name.split(' ')[0]} )}
{/* Valor */}
{fmtBRL(e.value)}
{sharedReceivable > 0 && (
↩ {fmtBRL(sharedReceivable)}
)} {e.shared && e.sharedWith?.length > 0 && e.memberId !== pmOwnerId && e.sharedWith.some(sw => sw.memberId === pmOwnerId) && (
{e.sharedWith.find(sw => sw.memberId === pmOwnerId)?.pct?.toFixed(0)}% de {fmtBRL(e.value)}
)}
{/* Ações */}
{!isPaid && !e.isProjected && ( )} {!isPaid && e.isProjected && ( Fixa )} {!isPaid && ( )}
); })}
)}
{/* Fatura canônica: ciclo, itens e total vêm integralmente do backend. */} {invoiceDialog && (
{ if (e.target === e.currentTarget && !invoiceAction) setInvoiceDialog(null); }} >

Fatura — {invoiceDialog.name}

Vencimento em {monthNames[viewMonth.month - 1]} de {viewMonth.year}
{invoiceLoading && (
)} {!invoiceLoading && invoiceError && (
{invoiceError}
)} {!invoiceLoading && !invoicePreview && (
Não foi possível apresentar esta fatura.
)} {!invoiceLoading && invoicePreview && ( <>
Ciclo da fatura
{new Date(invoicePreview.cycle.startDate + 'T00:00:00').toLocaleDateString('pt-BR')} a {new Date(invoicePreview.cycle.endDate + 'T00:00:00').toLocaleDateString('pt-BR')}
Vence em {new Date(invoicePreview.cycle.dueDate + 'T00:00:00').toLocaleDateString('pt-BR')}
{invoicePreview.status === 'paid' ? 'Paga' : invoicePreview.status === 'open' ? 'Aberta' : 'Pendente'}
{fmtBRL(invoicePreview.total)}
{invoicePreview.itemCount} item{invoicePreview.itemCount !== 1 ? 's' : ''}
{invoicePreview.items.length === 0 ? ( ) : (
{invoicePreview.items.map((item, index) => (
{item.description}
{new Date(item.purchaseDate + 'T00:00:00').toLocaleDateString('pt-BR')}
{fmtBRL(item.value)}
))}
)} {invoicePreview.canReopen && (