// Financial reports and monthly plans use canonical, authenticated API results.
function FFPlanningButton({ children, primary = false, ...props }) {
return {children} ;
}
function FFPlanningError({ error, retry }) {
return error ?
{error} {retry && Tentar novamente }
: null;
}
function FFPlanningPercent({ bps }) { return <>{(bps / 100).toLocaleString('pt-BR', { maximumFractionDigits: 2 })}%>; }
function FFSpendingReports({ onNavigate }) {
const { currentMonth, currentMember } = useApp();
const [result, setResult] = React.useState(null);
const [error, setError] = React.useState('');
const [loading, setLoading] = React.useState(true);
const [retry, setRetry] = React.useState(0);
const month = `${currentMonth.year}-${String(currentMonth.month).padStart(2, '0')}`;
const today = new Date();
const asOf = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const key = `${currentMember?.id}:${month}:${asOf}`;
React.useEffect(() => {
let active = true;
const controller = new AbortController(), token = localStorage.getItem('ff_token');
setLoading(true); setError(''); setResult(null);
apiFetch(`/reports/spending?month=${month}&asOf=${asOf}`, { signal: controller.signal })
.then(data => { if (active && localStorage.getItem('ff_token') === token) setResult({ key, data }); })
.catch(e => { if (active && e.name !== 'AbortError') setError(e.message); })
.finally(() => { if (active) setLoading(false); });
return () => { active = false; controller.abort(); };
}, [key, retry]);
const data = result?.key === key ? result.data : null;
const panels = data ? [
{ title: 'Despesas por meio de pagamento', sub: `Sua responsabilidade no mês ${month.split('-').reverse().join('/')}. O percentual considera suas cotas, não o valor integral de terceiros.`, report: data.monthly, daily: false },
{ title: 'Dia a Dia · últimos 90 dias', sub: `${parseLocalDate(data.daily90.from).toLocaleDateString('pt-BR')} a ${parseLocalDate(data.daily90.to).toLocaleDateString('pt-BR')} · sua responsabilidade por descrição do gasto.`, report: data.daily90, daily: true },
] : [];
return
Análise para o orçamento Percentuais e valores da sua responsabilidade.
onNavigate('orcamento')}>Abrir orçamento mensal
{loading && Carregando relatórios…
}
setRetry(r => r + 1)} />
{panels.map(panel =>
{panel.title} {panel.sub}
{fmtBRL(panel.report.totalCents / 100)}
{!panel.report.rows.length ? :
{panel.report.rows.map(row =>
{panel.daily ? row.description : row.name}
{fmtBRL(row.amountCents / 100)} {panel.daily && {row.count} lançamento{row.count === 1 ? '' : 's'} · estimativa mensal {fmtBRL(row.monthlyEstimateCents / 100)} }
)}
}
{panel.daily && A estimativa é a média de 30 dias do período. Use-a para decidir quais gastos recorrentes incluir no orçamento; ela não cria uma despesa.
}
)}
;
}
function FFBudgetShareDraft(members, shares, currentMemberId) {
const source = shares || [{ memberId: currentMemberId, percentageBps: 10000 }];
const participants = [...members, ...source.filter(s => !members.some(m => m.id === s.memberId)).map(s => ({ id: s.memberId, name: 'Membro indisponível' }))];
return participants.map(m => { const s = source.find(x => x.memberId === m.id); return { memberId: m.id, included: !!s, percent: s ? (s.percentageBps / 100).toFixed(2).replace('.', ',') : '' }; });
}
function FFBudgetDraft(plan, members, actor, month) {
return { name: plan?.name || `Orçamento ${month.split('-').reverse().join('/')}`, version: plan?.version || 0,
defaultShares: FFBudgetShareDraft(members, plan?.defaultShares, actor),
categoryRules: (plan?.categoryRules || []).map(r => ({ categoryId: r.categoryId, shares: FFBudgetShareDraft(members, r.shares, actor) })),
items: (plan?.items || []).map(i => ({ id: i.id, description: i.description, categoryId: i.categoryId, amount: (i.amountCents / 100).toFixed(2).replace('.', ','), customShares: !!i.sharesOverride, shares: FFBudgetShareDraft(members, i.sharesOverride || i.shares, actor) })),
};
}
function FFBudgetShares({ label, members, value, onChange, disabled }) {
return {label}
Marque somente os participantes. Os percentuais selecionados devem somar 100%.
{value.map((row, index) => { const member = members.find(m => m.id === row.memberId); return
onChange(value.map((r, n) => n === index ? { ...r, included: e.target.checked } : r))} />{member?.name || 'Membro indisponível'}
Percentual de {member?.name || 'membro'} · {label} onChange(value.map((r, n) => n === index ? { ...r, percent: e.target.value } : r))} />%
; })}
;
}
function FFBudgetSummary({ plan, members, shared = false }) {
return {shared ? plan.name : plan.preview ? 'Prévia do orçamento' : 'Totais do orçamento salvo'}
{shared && Compartilhado por {members.find(m => m.id === plan.ownerMemberId)?.name || 'outro membro'} · somente os itens em que você participa. Consulta sem edição.
}
{fmtBRL(plan.totalCents / 100)}
{(plan.totalsByMember || []).map(s => {members.find(m => m.id === s.memberId)?.name || 'Membro'} {fmtBRL(s.amountCents / 100)}
)}
{(plan.items || []).map((item, index) => {item.description} {fmtBRL(item.amountCents / 100)}
{!shared &&
Divisão: {({ item: 'personalizada neste item', category: 'regra da categoria', default: 'padrão do orçamento' })[item.shareSource]}
}
{item.shares.map(s =>
{members.find(m => m.id === s.memberId)?.name || 'Membro'} · {fmtBRL(s.amountCents / 100)}
)}
)}
;
}
function OrcamentoMensal() {
const { currentMonth, currentMember, members, categories } = useApp();
const month = `${currentMonth.year}-${String(currentMonth.month).padStart(2, '0')}`;
const key = `${currentMember?.id}:${month}`;
const [loaded, setLoaded] = React.useState(null), [draftState, setDraftState] = React.useState(null);
const [loading, setLoading] = React.useState(true), [saving, setSaving] = React.useState(false);
const [loadError, setLoadError] = React.useState(''), [sharedError, setSharedError] = React.useState(''), [saveError, setSaveError] = React.useState(''), [notice, setNotice] = React.useState('');
const [retry, setRetry] = React.useState(0);
const [preview, setPreview] = React.useState(null), [previewing, setPreviewing] = React.useState(false), [previewError, setPreviewError] = React.useState('');
const previewSequence = React.useRef(0), liveDraftSignature = React.useRef('');
const liveKey = React.useRef(key); liveKey.current = key;
const generation = React.useRef(0);
const draft = draftState?.key === key ? draftState.data : null;
const plans = loaded?.key === key ? loaded : null;
const draftSignature = JSON.stringify(draft); liveDraftSignature.current = draftSignature;
const currentPreview = preview?.key === key && preview.signature === draftSignature ? preview.plan : null;
React.useEffect(() => {
let active = true;
generation.current++; previewSequence.current++; setSaving(false); setPreviewing(false); setPreview(null); setPreviewError('');
const controller = new AbortController(), token = localStorage.getItem('ff_token');
setLoading(true); setLoadError(''); setSharedError(''); setSaveError(''); setNotice(''); setLoaded(null); setDraftState(null);
Promise.allSettled([apiFetch(`/budgets/${month}`, { signal: controller.signal }), apiFetch(`/budgets/${month}/shared`, { signal: controller.signal })]).then(([own, shared]) => {
if (!active || localStorage.getItem('ff_token') !== token) return;
setLoaded({ key, own: own.status === 'fulfilled' ? own.value : null, shared: shared.status === 'fulfilled' ? shared.value : [] });
if (own.status === 'fulfilled') setDraftState({ key, data: FFBudgetDraft(own.value, members, currentMember.id, month) }); else if (own.reason.name !== 'AbortError') setLoadError(own.reason.message);
if (shared.status === 'rejected' && shared.reason.name !== 'AbortError') setSharedError(shared.reason.message);
setLoading(false);
});
return () => { active = false; controller.abort(); };
}, [key, retry, members.map(m => m.id).join(':')]);
function update(change) { previewSequence.current++; setPreviewing(false); setPreview(null); setPreviewError(''); setDraftState(s => s?.key === key ? { key, data: { ...s.data, ...change } } : s); setNotice(''); }
function updateItem(index, change) { update({ items: draft.items.map((i, n) => n === index ? { ...i, ...change } : i) }); }
async function calculatePreview() {
if (previewing || saving || !draft) return;
setPreviewError(''); setSaveError('');
let payload; try { payload = window.FFBudgetInput.budgetToPayload(draft); } catch (e) { setPreviewError(e.message); return; }
const requestKey = key, signature = draftSignature, sequence = ++previewSequence.current, token = localStorage.getItem('ff_token');
setPreviewing(true); setPreview(null);
try {
const plan = await apiFetch(`/budgets/${month}/preview`, { method: 'POST', body: payload });
if (sequence === previewSequence.current && liveKey.current === requestKey && liveDraftSignature.current === signature && localStorage.getItem('ff_token') === token) setPreview({ key, signature, plan });
} catch (e) { if (sequence === previewSequence.current && liveKey.current === requestKey && localStorage.getItem('ff_token') === token) setPreviewError(e.message); }
finally { if (sequence === previewSequence.current && liveKey.current === requestKey) setPreviewing(false); }
}
async function save(event) {
event.preventDefault(); if (saving || !draft) return;
if (!currentPreview) { setSaveError('Calcule e confira a prévia atual antes de salvar.'); return; }
setSaveError(''); setNotice('');
let payload; try { payload = window.FFBudgetInput.budgetToPayload(draft); } catch (e) { setSaveError(e.message); return; }
const requestGeneration = generation.current, requestKey = key, token = localStorage.getItem('ff_token');
setSaving(true);
try {
const plan = await apiFetch(`/budgets/${month}`, { method: 'PUT', body: payload });
if (generation.current !== requestGeneration || liveKey.current !== requestKey || localStorage.getItem('ff_token') !== token) return;
setLoaded(s => ({ ...(s || {}), key, own: plan })); setDraftState({ key, data: FFBudgetDraft(plan, members, currentMember.id, month) }); setPreview(null); setNotice('Orçamento salvo. Nenhuma despesa ou cobrança foi criada.');
} catch (e) { if (generation.current === requestGeneration && liveKey.current === requestKey && localStorage.getItem('ff_token') === token) setSaveError(e.status === 409 ? 'O orçamento foi atualizado em outra tela. Seu rascunho está preservado. Recarregue a versão salva antes de editar novamente.' : e.message); }
finally { if (generation.current === requestGeneration && liveKey.current === requestKey) setSaving(false); }
}
return
Orçamento mensal Planejamento de {month.split('-').reverse().join('/')} · seu plano pode incluir qualquer membro da família.
Planeje com divisões flexíveis Defina uma divisão padrão. Uma regra por categoria substitui esse padrão; uma divisão por item tem prioridade sobre as duas. Quem não participa fica fora daquela despesa. Salvar o orçamento não cria lançamentos, receitas ou dívidas, nem altera o limite de Dia a Dia.
{loading &&
Carregando orçamento…
}
setRetry(r => r + 1)} />
{draft && }
{plans?.own && }
Planos compartilhados comigo setRetry(r => r + 1)} />
{!loading && !sharedError && plans && !plans.shared.length && Nenhum plano de outro membro inclui você neste mês.
}
{(plans?.shared || []).map(plan => )}
;
}
Object.assign(window, { FFSpendingReports, OrcamentoMensal });