// ============================================================ // FINAFAMILY — Receitas Screen // ============================================================ const { useState, useMemo, useEffect } = React; function IncomeForm({ onClose, onSave, initialValues }) { const { members, currentMember, incomeSources } = useApp(); const today = todayLocalISO(); const sources = (incomeSources && incomeSources.length) ? incomeSources : [ { key: 'salary', label: 'Salário' }, { key: 'freelance', label: 'Freelance' }, { key: 'passive', label: 'Renda Passiva' }, { key: 'rent', label: 'Aluguel' }, { key: 'other', label: 'Outros' }, ]; const defaultSource = sources[0]?.key || 'salary'; const isEditing = !!initialValues; const [form, setForm] = useState(() => initialValues ? { ...initialValues, value: String(initialValues.value ?? ''), status: initialValues.status || 'expected', } : { desc: '', value: '', date: today, memberId: currentMember?.id || '', source: defaultSource, recurrence: 'monthly', status: 'expected', }); const [errors, setErrors] = useState({}); function set(k, v) { setForm(f => ({ ...f, [k]: v })); } function handleSave() { const e = {}; if (!form.desc || !form.desc.trim()) e.desc = 'Descrição obrigatória'; const val = parseFloat(String(form.value).replace(',', '.')); if (!form.value || isNaN(val) || val <= 0) e.value = 'Informe um valor válido'; if (Object.keys(e).length) { setErrors(e); return; } const memberId = form.memberId || currentMember?.id; onSave({ ...form, value: val, memberId, id: initialValues?.id }); onClose(); } return (
set('desc', e.target.value)} placeholder="Ex: Salário Maio" />
set('value', e.target.value)} placeholder="0,00" min="0.01" step="0.01" /> set('date', e.target.value)} />
); } const SOURCE_COLORS_DEFAULT = { salary: '#10B981', freelance: '#3B82F6', passive: '#8B5CF6', rent: '#F59E0B', third_party: '#0D9488', other: '#94A3B8' }; const COLOR_PALETTE = ['#10B981','#3B82F6','#8B5CF6','#F59E0B','#EF4444','#0D9488','#F97316','#EC4899']; function getSourceLabel(key, incomeSources) { const found = incomeSources.find(s => s.key === key); if (found) return found.label; if (key === 'third_party') return 'Previsto Terceiro'; return key; } function getSourceColor(key, index) { return SOURCE_COLORS_DEFAULT[key] || COLOR_PALETTE[index % COLOR_PALETTE.length] || '#94A3B8'; } function Receitas({ onNavigate = () => {} }) { const { incomes, monthIncomes, addIncome, deleteIncome, updateIncome, markAllReceivedByMonth, members, currentMember, currentMonth, fetchMonthIncomes, incomeSources, myReceivables, totalIncome, totalProprio, totalAReceber } = useApp(); const [showForm, setShowForm] = useState(false); const [toast, setToast] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState(null); const [editingIncome, setEditingIncome] = useState(null); const [expandedMembers, setExpandedMembers] = useState({}); const [receiveConfirm, setReceiveConfirm] = useState(false); // modal bulk const [receiving, setReceiving] = useState(false); // Receitas do membro atual no mês — usa monthIncomes do contexto (já inclui projeção de mensais) const allMonthIncomes = useMemo(() => monthIncomes.filter(i => !isMemberReceivableIncome(i)), [monthIncomes]); // ── "A Receber": um item por Reembolso (devedor × despesa), mesma base do Balancete ── const [receivingId, setReceivingId] = useState(null); const aReceberItems = (myReceivables || []).map(i => ({ key: `tp:${i.id}`, incomeId: i.id, status: i.status || 'expected', settledBySettlement: !!i.settlementId, desc: (i.desc || '').replace(/^Reembolso — /, ''), debtorId: i.fromMemberId, amount: Number(i.value) || 0, date: i.date, label: i.origin === 'member_operation' || i.operationId ? 'Valor entre membros' : 'Reembolso', operationId: i.operationId, })); async function toggleReceived(item) { const next = item.status === 'received' ? 'expected' : 'received'; setReceivingId(item.incomeId); const r = await updateIncome(item.incomeId, { status: next }); setReceivingId(null); setToast(r === true ? (next === 'received' ? 'Recebimento confirmado — dívida quitada no Balancete.' : 'Recebimento desfeito — dívida reaberta.') : (r?.message || 'Não foi possível atualizar. Tente novamente.')); setTimeout(() => setToast(null), 3000); } // Usa os totais do contexto (fonte única de verdade, igual ao dashboard) const total = totalIncome; // Evolução real dos últimos 6 meses. Antes os meses passados eram // Math.random() — números inventados exibidos como se fossem o histórico. useEffect(() => { for (let i = 1; i <= 5; i++) { const d = new Date(currentMonth.year, currentMonth.month - 1 - i, 1); fetchMonthIncomes(d.getFullYear(), d.getMonth() + 1); } }, [currentMonth.year, currentMonth.month]); const MONTHS_PT_SHORT = ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez']; const barData = useMemo(() => { const months = []; for (let i = 5; i >= 0; i--) { const d = new Date(currentMonth.year, currentMonth.month - 1 - i, 1); months.push({ month: MONTHS_PT_SHORT[d.getMonth()], year: d.getFullYear(), m: d.getMonth() + 1 }); } return months.map((m, i) => ({ month: m.month, value: i === 5 ? total : incomes.filter(inc => { const d = parseLocalDate(inc.date); return inc.memberId === currentMember.id && d.getFullYear() === m.year && d.getMonth() + 1 === m.m; }).reduce((s, inc) => s + (Number(inc.value) || 0), 0), isCurrent: i === 5, })); }, [currentMonth, total, incomes, currentMember.id]); const maxVal = Math.max(...barData.map(d => d.value), 1); // Receitas previstas (expected) no mês atual — inclui projeções de mensais recorrentes const expectedIncomes = useMemo(() => allMonthIncomes.filter(i => i.status === 'expected'), [allMonthIncomes]); const expectedTotal = useMemo(() => expectedIncomes.reduce((s, i) => s + (i.value || 0), 0), [expectedIncomes]); async function handleReceiveAll() { setReceiving(true); try { const nonProjected = await markAllReceivedByMonth(currentMonth.month, currentMonth.year); // Projeções de mensais recorrentes não têm registro real — cria um por mês const projectedExpected = allMonthIncomes.filter(i => i._projected); for (const inc of projectedExpected) { await addIncome({ desc: inc.desc, value: inc.value, date: inc.date, source: inc.source, recurrence: 'single', status: 'received', memberId: inc.memberId, }); } const total = nonProjected + projectedExpected.length; setToast(`${total} receita${total !== 1 ? 's' : ''} marcada${total !== 1 ? 's' : ''} como recebida${total !== 1 ? 's' : ''}!`); setTimeout(() => setToast(null), 3000); } finally { setReceiving(false); setReceiveConfirm(false); } } return ( <>
{toast && setToast(null)} />} setShowForm(false)} title="Nova Receita"> setShowForm(false)} onSave={inc => { addIncome(inc); setToast('Receita cadastrada!'); }} /> setEditingIncome(null)} title="Editar Receita"> {editingIncome && ( setEditingIncome(null)} onSave={async (updated) => { const r = await updateIncome(updated.id, updated); setToast(r === true ? 'Receita atualizada!' : (r?.message || 'Não foi possível atualizar a receita.')); }} /> )} onNavigate('entre-membros')}>Valores entre membros · registrar recebimentos {/* ── Header ─────────────────────────────────────────────── */}
{fmtBRL(total)}
Receitas de {currentMember.name.split(' ')[0]} no mês {totalAReceber > 0 && ( ({fmtBRL(totalProprio)} próprias + {fmtBRL(totalAReceber)} a receber) )}
{expectedIncomes.length > 0 && ( )}
{/* ── Receitas do membro atual ── */} {allMonthIncomes.length === 0 && ( )} {allMonthIncomes.length > 0 && ( {allMonthIncomes.map((inc, idx) => { const srcIdx = incomeSources.findIndex(s => s.key === inc.source); const color = getSourceColor(inc.source, srcIdx); return (
e.currentTarget.style.background = 'var(--surface-2)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'} >
{inc.desc}
{getSourceLabel(inc.source, incomeSources)} • {inc.recurrence === 'monthly' ? 'Mensal' : 'Única'} • {parseLocalDate(inc.date).toLocaleDateString('pt-BR')}
{inc.status === 'expected' && !inc._projected && ( Prevista )} {fmtBRL(inc.value)}
{inc._projected ? (
Mensal
) : deleteConfirm === inc.id ? (
) : (
{inc.status === 'expected' ? ( ) : ( )}
)}
); })}
)} {/* ── A Receber — agrupado por devedor ──────────────────── */} {aReceberItems.length > 0 && (() => { // Agrupa itens por membro devedor const debtorMap = {}; aReceberItems.forEach(item => { const did = item.debtorId || 'outros'; if (!debtorMap[did]) { const m = members.find(x => x.id === did); debtorMap[did] = { member: m, items: [], total: 0 }; } debtorMap[did].items.push(item); debtorMap[did].total += item.amount || 0; }); const debtorGroups = Object.entries(debtorMap) .sort(([, a], [, b]) => b.total - a.total) .map(([did, v]) => ({ did, ...v })); return (
{/* Cabeçalho da seção A Receber */}
A Receber ({aReceberItems.length} {aReceberItems.length === 1 ? 'item' : 'itens'})
{fmtBRL(totalAReceber)}
{/* Um card por devedor */}
{debtorGroups.map(({ did, member: dm, items: dItems, total: dTotal }) => { const memberColor = dm?.color || '#10B981'; const isExpanded = !!expandedMembers[did]; return ( {/* Cabeçalho do grupo — clicável para expandir */}
setExpandedMembers(prev => ({ ...prev, [did]: !prev[did] }))} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 16px', cursor: 'pointer', userSelect: 'none' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'} > {/* Avatar do devedor */}
{(dm?.initials || dm?.name?.slice(0, 2) || '?').toUpperCase()}
{dm?.name?.split(' ')[0] || 'Desconhecido'}
{dItems.length} {dItems.length === 1 ? 'item' : 'itens'} · {isExpanded ? 'clique para recolher' : 'clique para ver'}
{fmtBRL(dTotal)} {(() => { const pending = dItems.filter(it => it.status !== 'received'); if (!pending.length) return ( Quitado ); return ( {pending.length} em aberto ); })()}
{/* Itens expandidos */} {isExpanded && (
{dItems.map((item, idx) => { const isReceived = item.status === 'received'; return (
e.currentTarget.style.background = 'var(--surface-2)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'} >
{item.desc}
{item.label} • {parseLocalDate(item.date).toLocaleDateString('pt-BR')}
{fmtBRL(item.amount)} {item.operationId ? onNavigate('entre-membros')}>Ver obrigação : item.settledBySettlement ? ( ✓ Quitado no acerto ) : isReceived ? ( ) : ( )}
); })}
)}
); })}
); })()}
{/* ── Painel lateral (gráfico + por fonte) ─────────────── */}
Evolução (6 meses)
{barData.map((d, i) => { const h = Math.round((d.value / maxVal) * 120); return (
{d.isCurrent ? fmtBRL(d.value).replace('R$ ', '') : ''}
{d.month}
); })}
Por fonte
{incomeSources.map(src => { const sum = allMonthIncomes.filter(inc => inc.source === src.key).reduce((s, inc) => s + inc.value, 0); if (!sum) return null; const pct = totalProprio > 0 ? (sum / totalProprio) * 100 : 0; return (
{src.label} {fmtBRL(sum)}
); })} {totalAReceber > 0 && (
A Receber {fmtBRL(totalAReceber)}
0 ? (totalAReceber / total) * 100 : 100} height={4} showGradient={false} />
)}
{/* ── Modal de confirmação — Receber Todas ─────────────── */} {receiveConfirm && (

Marcar todas como recebidas

Mês de referência: {['Janeiro','Fevereiro','Março','Abril','Maio','Junho','Julho','Agosto','Setembro','Outubro','Novembro','Dezembro'][currentMonth.month - 1]} {currentMonth.year}

Total previsto
{fmtBRL(expectedTotal)}
Receitas
{expectedIncomes.length}

As {expectedIncomes.length} receita{expectedIncomes.length !== 1 ? 's' : ''} prevista{expectedIncomes.length !== 1 ? 's' : ''} deste mês serão marcadas como recebidas.

)} ); } Object.assign(window, { Receitas });