// ============================================================
// JuFlow — Financeiro
// ============================================================
const { useState: useStFN, useMemo: useMemoFN } = React;
const UFN = window.JFUtils;
// ─── Native SVG Cash Flow Chart (no CDN dependency) ──────
const CashFlowChart = ({ data }) => {
const [hover, setHover] = useStFN(null);
const W = 760, H = 240;
const padL = 56, padR = 16, padT = 24, padB = 32;
const innerW = W - padL - padR;
const innerH = H - padT - padB;
const maxV = Math.max(1, ...data.flatMap(d => [d.entradas, d.saidas]));
const niceMax = Math.ceil(maxV / 5000) * 5000 || 5000;
const yScale = v => padT + innerH - (v / niceMax) * innerH;
const groupW = innerW / data.length;
const barW = Math.min(28, groupW / 3);
const yTicks = [0, 0.25, 0.5, 0.75, 1].map(t => Math.round(niceMax * t));
const fmtBR = v => 'R$ ' + (v >= 1000 ? (v/1000).toFixed(v % 1000 === 0 ? 0 : 1) + 'k' : v.toFixed(0));
// Saldo line points
const linePoints = data.map((d, i) => {
const cx = padL + groupW * i + groupW / 2;
const saldo = Math.max(0, d.entradas - d.saidas);
return { x: cx, y: yScale(saldo), saldo };
});
const lineD = linePoints.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
return (
{/* Tooltip */}
{hover !== null && (() => {
const d = data[hover];
const saldo = d.entradas - d.saidas;
const groupW = innerW / data.length;
const cx = padL + groupW * hover + groupW / 2;
const leftPct = (cx / W) * 100;
return (
{d.month}
● Entradas: {UFN.formatCurrency(d.entradas)}
● Saídas: {UFN.formatCurrency(d.saidas)}
= 0 ? '#0891B2' : '#EF4444', borderTop:'1px solid #F0F4F8', marginTop:4, paddingTop:4 }}>
Saldo: {UFN.formatCurrency(saldo)}
);
})()}
);
};
const FN_INCOME_CATS = ['Atendimento','Pacote vendido','Produto','Outros'];
const FN_EXPENSE_CATS = ['Aluguel','Material','Marketing','Manutenção','Utilities','Software','Pró-labore','Impostos','Outros'];
const EMPTY_TXN = (today) => ({
id:null, date: today.toISOString().split('T')[0],
description:'', category:'Atendimento', type:'income',
amount:0, status:'completed',
});
const Financeiro = ({ appointments, addToast }) => {
const [tab, setTab] = useStFN('movimentacoes');
const [period, setPeriod] = useStFN('30');
const [typeFilter, setTypeFilter] = useStFN('all');
const [extraTxns, setExtraTxns] = useStFN([]);
const [modal, setModal] = useStFN({ open:false, form: EMPTY_TXN(window.JF.TODAY) });
const allTxns = useMemoFN(() =>
[...window.JF.FINANCIALS, ...extraTxns].sort((a,b) => b.date.localeCompare(a.date))
, [extraTxns]);
const txns = useMemoFN(() => {
const cutoff = UFN.addDays(window.JF.TODAY, -Number(period));
let list = allTxns.filter(t => new Date(t.date) >= cutoff);
if (typeFilter !== 'all') list = list.filter(t => t.type === typeFilter);
return list;
}, [allTxns, period, typeFilter]);
const income = txns.filter(t=>t.type==='income').reduce((s,t)=>s+t.amount,0);
const expenses = txns.filter(t=>t.type==='expense').reduce((s,t)=>s+t.amount,0);
const balance = income - expenses;
// Monthly bar chart data (last 6 months)
const barData = useMemoFN(() => {
const months = ['Dez','Jan','Fev','Mar','Abr','Mai'];
return months.map((m, i) => {
const monthIdx = (window.JF.TODAY.getMonth() - 5 + i + 12) % 12;
const year = monthIdx > window.JF.TODAY.getMonth() ? window.JF.TODAY.getFullYear()-1 : window.JF.TODAY.getFullYear();
const monthTxns = allTxns.filter(t => {
const d = new Date(t.date);
return d.getMonth() === monthIdx && d.getFullYear() === year;
});
return {
month: m,
entradas: monthTxns.filter(t=>t.type==='income').reduce((s,t)=>s+t.amount,0),
saidas: monthTxns.filter(t=>t.type==='expense').reduce((s,t)=>s+t.amount,0),
};
});
}, [allTxns]);
const openCreate = () => setModal({ open:true, form: { ...EMPTY_TXN(window.JF.TODAY), category: FN_INCOME_CATS[0] } });
const closeModal = () => setModal(m => ({ ...m, open:false }));
const setForm = (k, v) => setModal(m => {
const next = { ...m.form, [k]: v };
if (k === 'type') {
const cats = v === 'income' ? FN_INCOME_CATS : FN_EXPENSE_CATS;
if (!cats.includes(next.category)) next.category = cats[0];
}
return { ...m, form: next };
});
const saveTxn = () => {
const f = modal.form;
if (!f.description.trim()) { addToast('Informe uma descrição.', 'warning'); return; }
if (!f.amount || f.amount <= 0) { addToast('Informe um valor válido.', 'warning'); return; }
const id = Math.max(0, ...allTxns.map(t=>t.id||0)) + 1;
setExtraTxns(arr => [...arr, { ...f, id }]);
addToast(`${f.type==='income'?'Entrada':'Saída'} de ${UFN.formatCurrency(f.amount)} registrada!`, 'success');
closeModal();
};
// Commission tab data
const commissions = useMemoFN(() =>
window.JF.PROFESSIONALS.map(prof => {
const cutoff = UFN.addDays(window.JF.TODAY, -Number(period));
const profAppts = appointments.filter(a =>
a.professionalId === prof.id &&
a.status === 'completed' &&
new Date(a.datetime) >= cutoff
);
const revenue = profAppts.reduce((s,a)=>s+a.price,0);
const commission = revenue * (prof.commission/100);
return { prof, sessions: profAppts.length, revenue, commission };
}).filter(c => c.sessions > 0)
, [appointments, period]);
// Pending receivables
const pendingAppts = useMemoFN(() =>
appointments
.filter(a => a.status==='confirmed' && !a.paymentMethod)
.sort((a,b) => new Date(b.datetime)-new Date(a.datetime))
.slice(0,10)
, [appointments]);
return (
{/* Top metrics */}
Entradas do Mês
{UFN.formatCurrency(income)}
Saídas do Mês
{UFN.formatCurrency(expenses)}
=0?'#0891B2':'#EF4444'}` }}>
Saldo
=0?'#0891B2':'#EF4444' }}>{UFN.formatCurrency(balance)}
{/* Bar chart */}
{(() => {
const totalIn = barData.reduce((s,d)=>s+d.entradas,0);
const totalEx = barData.reduce((s,d)=>s+d.saidas,0);
const totalSal = totalIn - totalEx;
const prevIn = barData.slice(0,3).reduce((s,d)=>s+d.entradas,0);
const recIn = barData.slice(3).reduce((s,d)=>s+d.entradas,0);
const trend = prevIn > 0 ? ((recIn - prevIn) / prevIn) * 100 : 0;
return (
Fluxo de Caixa — Últimos 6 meses
Saldo acumulado: =0 ? '#0891B2' : '#EF4444' }}>{UFN.formatCurrency(totalSal)}
=0 ? '#F0FDF4' : '#FEF2F2', color: trend>=0 ? '#15803D' : '#B91C1C' }}>
=0 ? 'TrendingUp' : 'TrendingDown'} size={11} />
{trend>=0?'+':''}{trend.toFixed(1)}% vs trimestre anterior
Entradas {UFN.formatCurrency(totalIn)}
Saídas {UFN.formatCurrency(totalEx)}
Saldo mensal
);
})()}
{/* Tabs */}
{tab === 'movimentacoes' && (
{/* Sub-filters */}
{[['all','Todos'],['income','Entradas'],['expense','Saídas']].map(([v,l]) => (
))}
{txns.length} registros
{['Data','Descrição','Categoria','Tipo','Valor','Status'].map(h => (
| {h} |
))}
{txns.slice(0,30).map((t, i) => (
e.currentTarget.style.background='#F9FAFB'}
onMouseLeave={e=>e.currentTarget.style.background='#fff'}
>
| {UFN.formatDate(t.date)} |
{t.description} |
{t.category} |
{t.type==='income'?'Entrada':'Saída'}
|
{t.type==='income'?'+':'-'} {UFN.formatCurrency(t.amount)}
|
|
))}
)}
{tab === 'comissoes' && (
Comissões por Profissional
{['Profissional','Especialidade','Sessões','Faturado','% Comissão','A Receber'].map(h => (
| {h} |
))}
{commissions.map(({ prof, sessions, revenue, commission }) => (
e.currentTarget.style.background='#F9FAFB'}
onMouseLeave={e=>e.currentTarget.style.background='#fff'}
>
|
|
{prof.role} |
{sessions} |
{UFN.formatCurrency(revenue)} |
|
{UFN.formatCurrency(commission)} |
))}
)}
{tab === 'receber' && (
Contas a Receber
{pendingAppts.length === 0 ? (
) : (
{['Cliente','Serviço','Data','Valor','Ação'].map(h=>(
| {h} |
))}
{pendingAppts.map(a => {
const c = UFN.getClient(a.clientId), s = UFN.getService(a.serviceId);
return (
|
{s?.name} |
{UFN.formatDate(a.datetime)} |
{UFN.formatCurrency(a.price)} |
|
);
})}
)}
)}
{/* Nova Movimentação modal */}
>}
>
{/* Type toggle */}
{[
{ v:'income', label:'Entrada', icon:'ArrowDownLeft', color:'#059669', desc:'Recebimento' },
{ v:'expense', label:'Saída', icon:'ArrowUpRight', color:'#EF4444', desc:'Despesa' },
].map(opt => {
const active = modal.form.type === opt.v;
return (
);
})}
setForm('description',v)} icon="FileText" required autoFocus placeholder="Ex: Atendimentos do dia, Pagamento aluguel..." />
{/* Amount + Date */}
setForm('amount', Number(e.target.value))}
style={{ padding:'9px 12px', borderRadius:8, fontSize:13, border:'1.5px solid #E2E8F0', outline:'none', color:'#0A1F44', background:'#fff' }} />
setForm('date', e.target.value)}
style={{ padding:'9px 12px', borderRadius:8, fontSize:13, border:'1.5px solid #E2E8F0', outline:'none', color:'#0A1F44', background:'#fff' }} />
{/* Category */}
{(modal.form.type === 'income' ? FN_INCOME_CATS : FN_EXPENSE_CATS).map(c => (
))}
{/* Status */}
{[
{ v:'completed', label:'Realizado', color:'#059669' },
{ v:'pending', label:'Pendente', color:'#D97706' },
].map(opt => {
const active = modal.form.status === opt.v;
return (
);
})}
);
};
Object.assign(window, { Financeiro });