🔄 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