// ============================================================ // FINAFAMILY — Despesas Screen // ============================================================ const { useState, useMemo, useEffect, useRef } = React; // Meio e responsabilidade são conceitos independentes: o meio informa seu dono, // enquanto o responsável individual ou as cotas definem quem assume o valor. function ResponsibilityPanel({ form, setForm, members, paymentMethods, error }) { if (!form.pmId) return null; const selectedPm = paymentMethods.find(pm => pm.id === form.pmId); const paymentOwner = members.find(member => member.id === selectedPm?.memberId); const creator = members.find(member => member.id === form.memberId); const mode = form.shareMode || 'percentage'; const responsibleIds = form.shared ? (form.sharedWith || []).map(share => share.memberId) : [form.responsibleMemberId].filter(Boolean); const approvalIds = [...new Set([ ...(paymentOwner?.id && paymentOwner.id !== creator?.id ? [paymentOwner.id] : []), ...responsibleIds.filter(memberId => memberId !== creator?.id), ])]; const responsibleNames = responsibleIds.map(id => members.find(member => member.id === id)?.name?.split(' ')[0]).filter(Boolean); const approvalNames = approvalIds.map(id => members.find(member => member.id === id)?.name?.split(' ')[0]).filter(Boolean); function equalShares(memberIds, nextMode) { const totalUnits = nextMode === 'percentage' ? 10000 : (moneyToCents(form.value) || 0); return allocateEvenly(totalUnits, memberIds).map(row => nextMode === 'percentage' ? { memberId: row.memberId, mode: nextMode, pct: row.units / 100, amount: '' } : { memberId: row.memberId, mode: nextMode, pct: 0, amount: (row.units / 100).toFixed(2) }); } function setShared(nextShared) { setForm(current => ({ ...current, shared: nextShared, responsibleMemberId: nextShared ? '' : (current.responsibleMemberId || current.memberId), sharedWith: nextShared ? equalShares(members.map(member => member.id), current.shareMode || 'percentage') : [], })); } function setMode(nextMode) { const ids = (form.sharedWith || []).map(share => share.memberId); setForm(current => ({ ...current, shareMode: nextMode, sharedWith: equalShares(ids, nextMode) })); } function toggleMember(memberId) { const selected = (form.sharedWith || []).some(share => share.memberId === memberId); const ids = selected ? form.sharedWith.filter(share => share.memberId !== memberId).map(share => share.memberId) : [...form.sharedWith.map(share => share.memberId), memberId]; setForm(current => ({ ...current, sharedWith: equalShares(ids, mode) })); } return (
Meio: {selectedPm?.name} · dono: {paymentOwner?.name || 'Não identificado'}
Responsabilidade
Independente do dono do meio
{!form.shared ? (
) : (
{members.map(member => { const share=(form.sharedWith||[]).find(item=>item.memberId===member.id); const selected=!!share; return
{selected &&
{const value=event.target.value;setForm(current=>({...current,sharedWith:current.sharedWith.map(item=>item.memberId===member.id?{...item,[mode==='amount'?'amount':'pct']:value}:item)}))}} style={{width:'100%',minWidth:0}}/>{mode==='amount'?'R$':'%'}
}
; })}
)} {error &&
{error}
}
Revisão: {creator?.name?.split(' ')[0] || 'Você'} lançou · meio de {paymentOwner?.name?.split(' ')[0] || 'dono não identificado'} · responsabilidade {responsibleNames.join(', ') || 'não definida'}. Vale na hora para todos{approvalNames.length ? ` — ${approvalNames.join(', ')} ${approvalNames.length > 1 ? 'serão avisados' : 'será avisado(a)'}` : ''}.
); } function ExpenseForm({ onClose, onSave, initialValues, onSavingChange = () => {}, onMemberOperationSaved }) { const { categories, paymentMethods, members, currentMember, fetchExpenseSeriesDetails, fetchMemberOperationConversionEligibility } = useApp(); const today = todayLocalISO(); const defaultCatId = categories.length > 0 ? categories[0].id : ''; const isEditing = !!initialValues; // Para parceladas em edição: exibe o valor TOTAL (parcela × nº parcelas) const isEditingInstallment = isEditing && initialValues?.type === 'installment' && (initialValues?.installments || 1) > 1; const isEditingSeries = isEditing && !!initialValues?.seriesId; const initialShares = initialValues?.seriesResponsibilityShares || initialValues?.sharedWith || []; const submissionKeyRef = useRef(globalThis.crypto?.randomUUID?.() || `expense-submit:${Date.now()}`); const baselineRef = useRef(null); const [form, setForm] = useState(() => initialValues ? { ...initialValues, value: isEditingInstallment ? String((initialValues.seriesTotal ?? ((parseFloat(initialValues.value) || 0) * (initialValues.installments || 1))).toFixed(2)) : String(initialValues.value ?? ''), installments: initialValues.installments || 2, currentInstallment: initialValues.currentInstallment || 1, frequency: initialValues.frequency || 'monthly', sharedWith: initialShares, shareMode: initialShares[0]?.mode || 'percentage', responsibleMemberId: initialValues.responsibleMemberId || currentMember?.id || '', editScope: 'occurrence', } : { desc: '', value: '', date: today, categoryId: defaultCatId, type: 'single', status: 'pending', memberId: currentMember?.id || '', pmId: '', shared: false, visibility: 'private', blocked: false, installments: 2, currentInstallment: 1, frequency: 'monthly', durationChoice: '', durationMonths: '', sharedWith: [], shareMode: 'percentage', responsibleMemberId: currentMember?.id || '', editScope: 'occurrence', }); const [operationMode, setOperationMode] = useState('expense'); const [memberOperationBusy, setMemberOperationBusy] = useState(false); const [conversionEligibility, setConversionEligibility] = useState(null); const [conversionEligibilityLoading, setConversionEligibilityLoading] = useState(isEditing); const [conversionEligibilityError, setConversionEligibilityError] = useState(''); const [conversionEligibilityAttempt, setConversionEligibilityAttempt] = useState(0); const [errors, setErrors] = useState({}); const [saving, setSaving] = useState(false); const [submissionError, setSubmissionError] = useState(''); const [seriesLoading, setSeriesLoading] = useState(isEditingSeries); const [seriesLoadError, setSeriesLoadError] = useState(''); const [seriesLoadAttempt, setSeriesLoadAttempt] = useState(0); React.useEffect(() => { if (!isEditing) return; let active = true; setConversionEligibilityLoading(true); setConversionEligibilityError(''); setConversionEligibility(null); fetchMemberOperationConversionEligibility(initialValues.id).then(eligibility => { if (active) setConversionEligibility(eligibility); }) .catch(failure => { if (active) setConversionEligibilityError(failure.message || 'Não foi possível verificar a modalidade deste lançamento.'); }) .finally(() => { if (active) setConversionEligibilityLoading(false); }); return () => { active = false; }; }, [isEditing, initialValues?.id, conversionEligibilityAttempt]); React.useEffect(() => { if (!isEditingSeries) return; let active = true; setSeriesLoading(true); setSeriesLoadError(''); fetchExpenseSeriesDetails(initialValues.seriesId).then(({ series, occurrences }) => { if (!active) return; const occurrence = occurrences.find(item => item.id === initialValues.id); if (!occurrence) throw new Error('A ocorrência editada não pertence à série retornada.'); const responsibilityShares = series.responsibilityShares || []; if (occurrence.shared && !responsibilityShares.length) { throw new Error('A API não retornou as cotas consolidadas da série.'); } if (occurrence.type === 'installment' && (!Number.isSafeInteger(Number(series.installmentTotalCents)) || Number(series.installmentTotalCents) <= 0)) { throw new Error('A API não retornou o valor total canônico da série parcelada.'); } const hydrated = { ...occurrence, value: occurrence.type === 'installment' ? String((Number(series.installmentTotalCents) / 100).toFixed(2)) : String(occurrence.value.toFixed(2)), date: occurrence.date, frequency: series.frequency || form.frequency, seriesVersion: Number(series.currentVersion), seriesTotal: series.installmentTotalCents != null ? Number(series.installmentTotalCents) / 100 : undefined, sharedWith: responsibilityShares, shareMode: responsibilityShares[0]?.mode || form.shareMode, }; baselineRef.current = hydrated; setForm(current => ({ ...current, ...hydrated, })); }).catch(err => { if (active) setSeriesLoadError(err?.message || 'Não foi possível carregar a série.'); }).finally(() => { if (active) setSeriesLoading(false); }); return () => { active = false; }; }, [isEditingSeries, initialValues?.seriesId, initialValues?.id, seriesLoadAttempt]); // Update categoryId when categories load (async) — só para novos lançamentos React.useEffect(() => { if (!isEditing && !form.categoryId && categories.length > 0) { setForm(f => ({ ...f, categoryId: categories[0].id })); } }, [categories]); // Sincroniza apenas a identidade informativa e o responsável padrão quando /auth/me termina. React.useEffect(() => { if (!isEditing && !form.memberId && currentMember?.id) { setForm(f => ({ ...f, memberId: currentMember.id, responsibleMemberId: f.responsibleMemberId || currentMember.id })); } }, [currentMember]); function set(key, val) { setSubmissionError(''); setForm(f => ({ ...f, [key]: val })); } function validate() { const e = {}; if (!form.desc.trim()) e.desc = 'Descrição obrigatória'; if (!form.value || moneyToCents(form.value) == null || moneyToCents(form.value) <= 0) e.value = 'Valor inválido'; if (!form.pmId) e.pmId = 'Selecione um meio de pagamento'; if (!form.shared && !form.responsibleMemberId) e.responsibility = 'Selecione o responsável'; const division = validateExpenseDivision(form.value, form.shared, form.sharedWith, form.shareMode); if (!division.ok) e.responsibility = division.error; if (isEditingSeries && !['occurrence','future','series'].includes(form.editScope)) e.editScope = 'Escolha o escopo da edição'; if (form.type === 'installment' && !isEditingSeries) { const n = parseInt(form.installments, 10); if (!Number.isInteger(n) || n < 2) e.installments = 'Informe ao menos 2 parcelas'; else if (n > 60) e.installments = 'Máximo de 60 parcelas'; } if (form.type === 'fixed' && !isEditing) { const durationError = validateFixedDuration(form.frequency, form.durationChoice, form.durationMonths); if (durationError) e.duration = durationError; } return e; } async function handleSave() { if (saving) return; if (seriesLoading || seriesLoadError) { setSubmissionError(seriesLoadError || 'Aguarde o carregamento completo da série.'); return; } const e = validate(); if (Object.keys(e).length) { setErrors(e); return; } setErrors({}); setSubmissionError(''); setSaving(true); onSavingChange(true); try { await onSave({ ...form, value: parseFloat(form.value), installments: form.type === 'installment' ? parseInt(form.installments, 10) : form.installments, id: initialValues?.id || 'e_' + Date.now(), _idempotencyKey: submissionKeyRef.current, _baseline: baselineRef.current, }); onClose(); } catch (err) { setSubmissionError(err?.message || 'Não foi possível salvar a despesa. Tente novamente.'); } finally { setSaving(false); onSavingChange(false); } } const myPMs = paymentMethods.filter(pm => pm.memberId === currentMember?.id); return ( <>
{isEditing && conversionEligibilityLoading &&
Verificando modalidades permitidas…
} {isEditing && conversionEligibilityError &&
{conversionEligibilityError} setConversionEligibilityAttempt(attempt => attempt + 1)}>Tentar novamente a verificação
} {isEditing && conversionEligibility && !conversionEligibility.eligible &&
Conversão indisponível: {conversionEligibility.reason || 'A origem não permite uma obrigação direta entre membros.'}
}
{operationMode === 'member_operation' ? { setMemberOperationBusy(value); onSavingChange(value); }} /> :
{seriesLoading &&
Carregando valor e cotas canônicas da série…
} {seriesLoadError &&
{seriesLoadError}
} set('desc', e.target.value)} placeholder="Ex: Supermercado" />
set('value', e.target.value)} placeholder="0,00" /> {isEditingInstallment && (
{form.value && !isNaN(parseFloat(form.value)) ? `${fmtBRL(parseFloat(form.value) / form.installments)} por parcela` : 'Informe o valor total'}
)}
set('date', e.target.value)} />
{form.type === 'fixed' && (
Recorrência da Despesa Fixa
{!isEditing && (
{form.durationChoice === 'custom' && ( set('durationMonths', e.target.value)} placeholder="Ex: 6" /> )}
)}
🔄 Vai repetir {form.frequency === 'weekly' ? 'toda semana' : form.frequency === 'biweekly' ? 'a cada 2 semanas' : 'todo mês'} a partir de {form.date ? new Date(form.date + 'T00:00:00').toLocaleDateString('pt-BR') : '—'} — {fmtBRL(parseFloat(form.value) || 0)} por ocorrência {!isEditing && form.durationChoice === 'open' && <>. Sem data de término: o app mantém sempre os próximos 12 meses lançados e você pode cancelar a série quando quiser.} {!isEditing && form.durationChoice && form.durationChoice !== 'open' && (() => { const n = fixedOccurrenceCount(form.frequency, form.durationChoice === 'custom' ? form.durationMonths : form.durationChoice); return n ? <>, {n} {n === 1 ? 'ocorrência' : 'ocorrências'} no total. : null; })()}
)} {form.type === 'installment' && (
Parcelamento
{/* Digitação livre — sem forçar um valor padrão a cada tecla, senão apagar o "2" inicial vira impossível (ele reaparece na hora). A validação (mínimo 2, obrigatório) só acontece ao salvar. */} set('installments', e.target.value)} /> set('date', e.target.value)} />
📅 {form.installments || '—'}x de {form.value ? fmtBRL(parseFloat(form.value) / (parseInt(form.installments) || 1)) : 'R$ —'} — todo mês no dia {form.date ? new Date(form.date + 'T00:00:00').getDate() : '—'}, por {form.installments || '—'} meses a partir de {form.date ? new Date(form.date + 'T00:00:00').toLocaleDateString('pt-BR', { month: 'short', year: 'numeric' }) : '—'}. Cada parcela acumula no mês correspondente.
)} {isEditingSeries && ( )} {/* Visibility */}
{form.visibility === 'open' ? '👁 Aberta para a família' : '🔒 Somente eu'}
{form.visibility === 'open' ? 'Qualquer membro da família pode ver' : 'Só você, os responsáveis e o dono do meio de pagamento veem'}
{submissionError && (
{submissionError}
)}
} ); } function Despesas({ onNavigate = () => {} }) { const { expenses, categories, members, currentMember, paymentMethods, addExpense, markExpensePaid, deleteExpense, deleteExpenseSeries, updateExpense, updateExpenseSeries, currentMonth, exportToCSV, diaDiaUsed, diaDiaLimit, diaDiaPct, sharedStatus, acceptSharedExpense, acceptSharedExpenses, balanceteExpenses, mySharedDebts } = 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 [filterStatus, setFilterStatus] = useState('all'); const [filterType, setFilterType] = useState('all'); const [filterCat, setFilterCat] = useState('all'); const [groupBy, setGroupBy] = useState('member'); const [showForm, setShowForm] = useState(false); const [showDiaDia, setShowDiaDia] = useState(false); const [ddDesc, setDdDesc] = useState(''); const [ddValue, setDdValue] = useState(''); const [ddDate, setDdDate] = useState(() => todayLocalISO()); const [ddError, setDdError] = useState(''); const [toast, setToast] = useState(null); const [search, setSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); const [deleteConfirm, setDeleteConfirm] = useState(null); const [deleteModal, setDeleteModal] = useState(null); // despesa de série (parcelada/fixa) const [editingExpense, setEditingExpense] = useState(null); const [expenseFormBusy, setExpenseFormBusy] = useState(false); const [approvingExpenseId, setApprovingExpenseId] = useState(null); const [ddPmId, setDdPmId] = useState(''); const [editingDiaDia, setEditingDiaDia] = useState(null); // {id, desc, value, date, pmId} // Debounce de 250ms para o campo de busca useEffect(() => { const t = setTimeout(() => setDebouncedSearch(search), 250); return () => clearTimeout(t); }, [search]); const filtered = useMemo(() => { return expenses.filter(e => { // Usa 'T00:00:00' para forçar leitura em horário local e evitar bug de fuso UTC-3 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; const isOwn = e.memberId === currentMember.id; const isSharedWithMe = e.shared && e.sharedWith?.some(sw => sw.memberId === currentMember.id); const isOnMyCard = e.thirdPartyMemberId === currentMember.id; const isOnMyPm = e.pmId && paymentMethods.find(pm => pm.id === e.pmId)?.memberId === currentMember.id; const isResponsible = expenseResponsibilityRows(e).some(row => row.responsibleMemberId === currentMember.id); // Regra única: a despesa envolve o membro (dono, no cartão dele, dividida ou terceiro) const involvesMe = expenseInvolvesMember(e, currentMember.id, paymentMethods); // Despesas privadas de outros membros só aparecem se forem do seu cartão/divididas const visibleToMe = isOwn || (e.visibility !== 'private') || isSharedWithMe || isResponsible || isOnMyCard || isOnMyPm; const inMember = involvesMe && visibleToMe; const statusOk = filterStatus === 'all' || e.status === filterStatus; const typeOk = filterType === 'all' || e.type === filterType; const catOk = filterCat === 'all' || e.categoryId === filterCat; const searchOk = !debouncedSearch || e.desc.toLowerCase().includes(debouncedSearch.toLowerCase()); return inMonth && inMember && statusOk && typeOk && catOk && searchOk; }).sort((a, b) => parseLocalDate(b.date) - parseLocalDate(a.date)); }, [expenses, filterStatus, filterType, filterCat, debouncedSearch, currentMonth, currentMember, paymentMethods]); // Group const grouped = useMemo(() => { if (groupBy === 'none') return [{ label: null, items: filtered }]; if (groupBy === 'category') { const map = {}; filtered.forEach(e => { const cat = displayCategories.find(c => c.id === e.categoryId); const key = cat?.id || 'outros'; if (!map[key]) map[key] = { label: cat?.name || 'Outros', color: cat?.color, items: [] }; map[key].items.push(e); }); return Object.values(map).sort((a, b) => b.items.reduce((s, e) => s + myShareValue(e, currentMember.id), 0) - a.items.reduce((s, e) => s + myShareValue(e, currentMember.id), 0) ); } if (groupBy === 'status') { const order = ['overdue','pending','paid']; const labels = { overdue: 'Vencidas', pending: 'Pendentes', paid: 'Pagas' }; const map = {}; filtered.forEach(e => { if (!map[e.status]) map[e.status] = { label: labels[e.status] || e.status, items: [] }; map[e.status].items.push(e); }); return order.filter(k => map[k]).map(k => map[k]); } if (groupBy === 'member') { const map = {}; const ensureMember = (mid) => { if (!map[mid]) { const m = members.find(x => x.id === mid); const isUnassigned = mid === UNASSIGNED_RESPONSIBLE_ID; map[mid] = { label: isUnassigned ? 'Sem responsável — revisar dados' : (m?.name || 'Responsável desconhecido'), color: isUnassigned ? '#EF4444' : m?.color, memberObj: m || null, integrityError: isUnassigned, items: [], }; } }; filtered.forEach(e => { expenseResponsibilityRows(e).forEach(row => { const mid = row.responsibleMemberId; ensureMember(mid); map[mid].items.push({ ...e, _displayValue: row.amountCents / 100, _displayAmountCents: row.amountCents, _displayPct: row.pct, _displayMode: row.mode, _integrityError: row.integrityError, }); }); }); return Object.values(map).sort((a, b) => b.items.reduce((s, e) => s + (e._displayAmountCents ?? e.valueCents ?? 0), 0) - a.items.reduce((s, e) => s + (e._displayAmountCents ?? e.valueCents ?? 0), 0) ); } if (groupBy === 'paymentMethod') { const map = {}; filtered.forEach(e => { const pm = paymentMethods.find(p => p.id === e.pmId); const key = pm?.id || 'sem-meio'; if (!map[key]) map[key] = { label: pm?.name || 'Sem meio de pagamento', color: pm?.color, items: [] }; map[key].items.push(e); }); return Object.values(map).sort((a, b) => b.items.reduce((s, e) => s + (e.valueCents || 0), 0) - a.items.reduce((s, e) => s + (e.valueCents || 0), 0) ); } if (groupBy === 'type') { const order = ['fixed','installment','single']; const labels = { fixed: 'Fixas', installment: 'Parceladas', single: 'Únicas' }; const icons = { fixed: '🔁', installment: '📦', single: '⚡' }; const map = {}; filtered.forEach(e => { const key = e.type; if (!map[key]) map[key] = { label: labels[key] || key, typeIcon: icons[key] || '', items: [] }; map[key].items.push(e); }); return order.filter(k => map[k]).map(k => map[k]); } return [{ label: null, items: filtered }]; }, [filtered, groupBy, categories, members, currentMember, paymentMethods]); // No agrupamento por responsável, soma somente as cotas canônicas em centavos. // Nas demais visões preserva o total cheio de cada despesa. const totalFiltered = groupBy === 'member' ? grouped.reduce((tot, group) => tot + group.items.reduce((s, e) => s + (e._displayAmountCents ?? e.valueCents ?? 0), 0), 0) / 100 : filtered.reduce((s, e) => s + (e.value || 0), 0); async function handleMarkPaid(exp) { const result = await markExpensePaid(exp); setDeleteConfirm(null); if (result === true) { setToast('Despesa marcada como paga!'); return; } const status = result?.status; const msg = status === 409 ? 'Despesa de cartão de crédito é quitada pela fatura: abra o cartão na tela Cartões e pague a fatura do mês.' : status === 403 ? 'Somente quem criou a despesa pode marcá-la como paga.' : (result?.message || 'Não foi possível marcar como paga. Tente novamente.'); setToast(msg); } async function handleDeleteExpense(id) { setDeleteConfirm(null); const ok = await deleteExpense(id); if (ok) { setToast('Despesa apagada.'); } else { setToast('Não foi possível apagar esta despesa. Apenas o criador pode excluí-la.'); } } // Despesa é de uma série recorrente? (parcelada com >1 ou fixa) → oferece escolha function isSeriesExpense(e) { return (e.type === 'installment' && parseInt(e.installments) > 1) || e.type === 'fixed'; } async function handleDeleteSeries(scope) { const e = deleteModal; if (!e) return; setDeleteModal(null); const ok = scope === 'one' ? await deleteExpense(e.id) : await deleteExpenseSeries(e, 'future'); if (ok) { const n = typeof ok === 'object' ? ok.deleted : null; const kept = typeof ok === 'object' ? ok.skipped : 0; setToast(scope === 'one' ? 'Despesa deste mês apagada.' : `${n != null ? `${n} ocorrência${n !== 1 ? 's' : ''} apagada${n !== 1 ? 's' : ''}` : 'Esta e as próximas despesas foram apagadas'}${kept ? ` (${kept} paga${kept !== 1 ? 's' : ''} ou em fatura mantida${kept !== 1 ? 's' : ''})` : ''}.`); } else { setToast('Não foi possível apagar: só quem lançou pode excluir, e despesas pagas ou em fatura ficam no histórico.'); } } // Retorna o valor canônico da responsabilidade em centavos normalizados. function myShareValue(exp, memberId) { const row = expenseResponsibilityRows(exp).find(item => item.responsibleMemberId === memberId); return row ? row.amountCents / 100 : 0; } // Dia a Dia expenses for current month const diaDiaExpenses = expenses.filter(e => { const d = parseLocalDate(e.date); return e.isDiaDia && e.memberId === currentMember.id && d.getFullYear() === currentMonth.year && d.getMonth() + 1 === currentMonth.month; }).sort((a, b) => parseLocalDate(b.date) - parseLocalDate(a.date)); async function handleDiaDiaLaunch() { if (!ddDesc.trim()) { setDdError('Informe o que foi gasto'); return; } if (!ddValue || isNaN(parseFloat(ddValue))) { setDdError('Valor inválido'); return; } const today = todayLocalISO(); const myPM = paymentMethods.find(pm => pm.memberId === currentMember?.id); try { await addExpense({ desc: ddDesc.trim(), value: parseFloat(ddValue), date: ddDate || today, isDiaDia: true, type: 'single', status: 'pending', memberId: currentMember?.id || '', categoryId: categories[0]?.id || '', pmId: ddPmId || myPM?.id || '', visibility: 'private', blocked: false, }); setDdDesc(''); setDdValue(''); setDdDate(today); setDdError(''); setToast('Gasto lançado no Dia a Dia!'); } catch (err) { setDdError(err?.message || 'Não foi possível lançar o gasto. Tente novamente.'); } } async function handleDiaDiaSave() { if (!editingDiaDia) return; const { id, desc, value, date, pmId } = editingDiaDia; if (!desc.trim() || isNaN(parseFloat(value))) return; try { await updateExpense(id, { desc: desc.trim(), value: parseFloat(value), date, pmId, isDiaDia: true, type: 'single' }); setEditingDiaDia(null); setToast('Gasto atualizado!'); } catch (err) { setToast(err?.message || 'Não foi possível atualizar o gasto. Tente novamente.'); } } function handleExportCSV() { const data = filtered.map(e => ({ data: parseLocalDate(e.date).toLocaleDateString('pt-BR'), descricao: e.desc, valor: e.value.toFixed(2), categoria: displayCategories.find(c => c.id === e.categoryId)?.name || '', tipo: e.type, status: e.status, compartilhado: e.shared ? 'Sim' : 'Não', visibilidade: e.visibility, parcela: e.type === 'installment' ? `${e.currentInstallment}/${e.installments}` : '', })); exportToCSV(data, `despesas-${currentMember.name.split(' ')[0].toLowerCase()}-mai2026.csv`); setToast('CSV exportado!'); } return (
{toast && setToast(null)} />} { if (!expenseFormBusy) setShowForm(false); }} title="Nova Despesa"> setShowForm(false)} onSavingChange={setExpenseFormBusy} onMemberOperationSaved={() => setToast('Acerto criado. Confira a receita prevista em Receitas ou Entre membros.')} onSave={async (exp) => { await addExpense(exp); setToast('Despesa cadastrada!'); }} /> { if (!expenseFormBusy) setEditingExpense(null); }} title="Editar Despesa"> {editingExpense && ( setEditingExpense(null)} onSavingChange={setExpenseFormBusy} onMemberOperationSaved={() => setToast('Despesa convertida. Confira a obrigação em Entre membros.')} onSave={async (updated) => { if (editingExpense.seriesId) { await updateExpenseSeries(updated, editingExpense, updated.editScope); } else { await updateExpense(updated.id, updated); } setToast('Despesa atualizada!'); }} /> )}
onNavigate('entre-membros')}>Valores entre membros · pagar ou receber
{/* ── Dia a Dia section ── */}
{/* Header — always visible */} {/* Expanded content */} {showDiaDia && (
{/* Quick launch form — grid real (r-stack colapsa para 1 coluna em mobile) */}
O que foi?
{ setDdDesc(e.target.value); setDdError(''); }} placeholder="Ex: Lanche, Uber, Farmácia…" onKeyDown={e => e.key === 'Enter' && handleDiaDiaLaunch()} />
Valor (R$)
{ setDdValue(e.target.value); setDdError(''); }} placeholder="0,00" onKeyDown={e => e.key === 'Enter' && handleDiaDiaLaunch()} />
Data
setDdDate(e.target.value)} />
Meio de Pagamento
{ddError &&
{ddError}
} {/* ── Extrato do Dia a Dia: programado (+) → saldo ── */}
Dia a Dia programado + {fmtBRL(diaDiaLimit)}
Gasto no mês - {fmtBRL(diaDiaUsed)}
Saldo do Dia a Dia = 0 ? 'var(--text-1)' : "var(--danger)") }}>{fmtBRL(diaDiaLimit - diaDiaUsed)}
{/* Dia a Dia list */} {diaDiaExpenses.length > 0 && (
Detalhe do que foi gasto
)} {diaDiaExpenses.length === 0 ? (
Nenhum gasto dia a dia neste mês
) : (
{diaDiaExpenses.map((e, idx) => { const isEditingThis = editingDiaDia?.id === e.id; const pmName = paymentMethods.find(p => p.id === e.pmId)?.name; return (
{isEditingThis ? ( /* ── Inline edit form ── */
Descrição
setEditingDiaDia(prev => ({ ...prev, desc: ev.target.value }))} autoFocus />
Valor (R$)
setEditingDiaDia(prev => ({ ...prev, value: ev.target.value }))} />
Data
setEditingDiaDia(prev => ({ ...prev, date: ev.target.value }))} />
Meio de Pagamento
) : ( /* ── Normal row ── */
{e.desc}
{pmName &&
{pmName}
}
{new Date(e.date + 'T00:00:00').toLocaleDateString('pt-BR')} {e.status === 'paid' && ( Pago )} - {fmtBRL(e.value)} {e.status !== 'paid' && ( deleteConfirm === e.id ? (
) : (
) )}
)}
); })}
Total gasto: - {fmtBRL(diaDiaExpenses.reduce((s, e) => s + e.value, 0))}
)}
)}
{/* ── Despesas compartilhadas aguardando confirmação ── */} {(() => { // Agrupa parcelas/ocorrências da MESMA série (parcelada ou fixa) em um único // item — aprovação de uma vez só, independente do tipo de despesa. Despesas // únicas ficam individuais. Ver Frontend/lib/finance-calc.js. const srcAll = (balanceteExpenses && balanceteExpenses.length ? balanceteExpenses : expenses); const groups = buildPendingApprovalGroups(mySharedDebts, srcAll, currentMember.id, sharedStatus); if (!groups.length) return null; return (
Aguardando sua aprovação
{groups.length} {groups.length > 1 ? 'itens' : 'item'} que requerem sua confirmação
{groups.map((g, idx) => { const e = g.rep; const launcher = members.find(m => m.id === e.memberId); // Despesa não dividida (shared:false) atribuída inteiramente a mim conta // como 100% — não existe cota em sharedWith pra esse caso. const myShare = e.shared ? e.sharedWith?.find(sw => sw.memberId === currentMember.id) : null; const myPct = e.shared ? (myShare?.pct || 0) : 100; const isFullResp = myPct === 100; // transferência total vs divisão parcial const cat = displayCategories.find(c => c.id === e.categoryId); const pm = paymentMethods.find(p => p.id === e.pmId); const isSeries = g.kind === 'series' && g.items.length > 1; // "parcelas" para installment, "ocorrências" para fixed recorrente const seriesUnitLabel = e.type === 'fixed' ? 'ocorrências' : 'parcelas'; // Soma da minha parte em todas as parcelas da série (ou só a despesa) 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); return (
{e.desc} {isSeries && {g.items.length}x {seriesUnitLabel}}
{launcher && {isFullResp ? 'Atribuído por' : 'Dividido por'} {launcher.name.split(' ')[0]}} {pm && · {pm.name}} {isSeries ? · {g.items.length} {seriesUnitLabel} : · {new Date(e.date + 'T00:00:00').toLocaleDateString('pt-BR')}}
{fmtBRL(myAmount)}
{isSeries ? `total · sua parte (${myPct.toFixed(0)}%)` : (isFullResp ? 'responsabilidade total' : `sua parte (${myPct.toFixed(0)}%)`)}
); })}
); })()} {/* Actions bar */}
setSearch(e.target.value)} placeholder="Buscar despesa…" style={{ width: '100%', boxSizing: 'border-box', background: 'var(--surface-1)', border: '1px solid var(--border)', borderRadius: 10, padding: '9px 10px 9px 32px', color: 'var(--text-1)', fontSize: 16, outline: 'none' }} />
{/* Filters */}
{[['all','Todos'],['paid','Pagas'],['pending','Pendentes'],['overdue','Vencidas']].map(([val, label]) => ( ))}
{/* Summary */}
{filtered.length} despesa{filtered.length !== 1 ? 's' : ''} Total: {fmtBRL(totalFiltered)}
{/* Expense list */} {filtered.length === 0 && } {grouped.map((group, gi) => (
{group.label && (
{/* Avatar para agrupamento por responsável */} {group.memberObj ? : group.typeIcon ? {group.typeIcon} : group.color ?
: null } {group.label} ({group.items.length})
{fmtBRL(group.items.reduce((s, e) => s + (e._displayAmountCents ?? e.valueCents ?? 0), 0) / 100)}
)} {group.items.map((e, idx) => { const cat = displayCategories.find(c => c.id === e.categoryId); const member = members.find(m => m.id === e.memberId); const pm = paymentMethods.find(p => p.id === e.pmId); const thirdParty = e.thirdPartyMemberId ? members.find(m => m.id === e.thirdPartyMemberId) : null; return (
e2.currentTarget.style.background = 'var(--surface-2)'} onMouseLeave={e2 => e2.currentTarget.style.background = 'transparent'} > {/* Category icon */}
{/* Main info */}
{e.desc} {parseLocalDate(e.date).toLocaleDateString('pt-BR')} {e.blocked && ( )} {e.shared && e.sharedWith?.length > 0 ? (
{e.sharedWith.map(sw => { const m = members.find(x => x.id === sw.memberId); return m ? : null; })}
) : (() => { // Sempre mostra quem é o responsável pelo pagamento — inclusive // quando é o próprio usuário logado — pra confirmar de cara que // a despesa está associada à pessoa certa. const responsible = members.find(m => m.id === e.responsibleMemberId); return responsible ? : null; })()} {thirdParty && ( Terceiro: {thirdParty.name.split(' ')[0]} )}
{cat?.name} {pm && <>•{pm.name}} {e.type === 'installment' && <>•{e.currentInstallment}/{e.installments}x}
{/* Badges */} {(() => { // Na lista normal mostra o VALOR CHEIO (soma = total exibido no topo). // No agrupamento por responsável usa a parcela de cada um (_displayValue). const inMemberGroup = e._displayValue != null; const effectiveValue = e._displayValue ?? e.value; const isSharedExpense = e.shared && e.sharedWith?.length > 0; const myResponsibility = expenseResponsibilityRows(e).find(row => row.responsibleMemberId === currentMember.id); return (
{fmtBRL(effectiveValue)} {inMemberGroup && e._displayPct != null && Math.abs(effectiveValue - e.value) > 0.01 && ( {e._displayPct.toFixed(0)}% de {fmtBRL(e.value)} )} {inMemberGroup && e._displayMode === 'amount' && ( cota por valor )} {inMemberGroup && e._integrityError && ( dados inconsistentes )} {!inMemberGroup && isSharedExpense && myResponsibility && ( sua parte: {fmtBRL(myResponsibility.amountCents / 100)}{myResponsibility.pct != null ? ` (${myResponsibility.pct.toFixed(0)}%)` : ''} )}
); })()} {/* Badges + ações: no mobile viram uma única linha, com os ícones alinhados à direita na mesma linha do status. */}
{e.origin === 'member_operation' || e.operationId ? onNavigate('entre-membros')}>Ver obrigação : deleteConfirm === e.id ? (
Apagar?
) : ( <> {e.status !== 'paid' && ( )} {e.status !== 'paid' && ( )} {e.status !== 'paid' && ( )} )}
); })}
))} {/* Modal: apagar despesa de série (parcelada/fixa) — escolha de escopo */} {deleteModal && (() => { const e = deleteModal; const isInst = e.type === 'installment'; const mesAno = new Date(e.date + 'T00:00:00').toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' }); const src = (balanceteExpenses && balanceteExpenses.length ? balanceteExpenses : expenses); const baseTime = new Date(e.date + 'T00:00:00').getTime(); const futureCount = src.filter(x => x.memberId === e.memberId && x.type === e.type && (x.desc || '').trim().toLowerCase() === (e.desc || '').trim().toLowerCase() && (!isInst || parseInt(x.installments) === parseInt(e.installments)) && x.status !== 'paid' && new Date(x.date + 'T00:00:00').getTime() >= baseTime ).length; const optStyle = { textAlign: 'left', width: '100%', padding: '12px 14px', borderRadius: 10, cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 2 }; return ( setDeleteModal(null)} title="Apagar despesa" width={420}>
{e.desc} é uma despesa {isInst ? 'parcelada' : 'fixa'}. Como você quer apagar?
); })()}
); } Object.assign(window, { Despesas, ExpenseForm });