// ============================================================
// 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 */}
{/* 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 (
);
})}
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); }}
>
{invoiceLoading && (
Carregando ciclo e lançamentos…
)}
{!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 && (
)}
>
)}
{invoicePreview && !invoiceLoading && (
)}
)}
{/* Modal de edição */}
setEditingExpense(null)} title="Editar Despesa">
{editingExpense && (
setEditingExpense(null)}
onSave={async (updated) => {
await updateExpense(updated.id, updated);
setToast('Despesa atualizada!');
}}
/>
)}
);
}
Object.assign(window, { Cartoes });