// ============================================================ // 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 (
{/* Y grid + labels */} {yTicks.map(v => ( {fmtBR(v)} ))} {/* Bars */} {data.map((d, i) => { const cx = padL + groupW * i + groupW / 2; const inH = innerH - (yScale(d.entradas) - padT); const exH = innerH - (yScale(d.saidas) - padT); const isHover = hover === i; return ( setHover(i)} onMouseLeave={() => setHover(null)} style={{ cursor:'pointer' }} > {/* Value labels on hover */} {isHover && ( <> {fmtBR(d.entradas)} {fmtBR(d.saidas)} )} {/* X label */} {d.month} ); })} {/* Saldo line */} {linePoints.map((p, i) => ( ))} {/* 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 => ( ))} {txns.slice(0,30).map((t, i) => ( e.currentTarget.style.background='#F9FAFB'} onMouseLeave={e=>e.currentTarget.style.background='#fff'} > ))}
{h}
{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 => ( ))} {commissions.map(({ prof, sessions, revenue, commission }) => ( e.currentTarget.style.background='#F9FAFB'} onMouseLeave={e=>e.currentTarget.style.background='#fff'} > ))}
{h}
{prof.name}
{prof.role} {sessions} {UFN.formatCurrency(revenue)}
{prof.commission}%
{UFN.formatCurrency(commission)}
)} {tab === 'receber' && (

Contas a Receber

{pendingAppts.length === 0 ? ( ) : ( {['Cliente','Serviço','Data','Valor','Ação'].map(h=>( ))} {pendingAppts.map(a => { const c = UFN.getClient(a.clientId), s = UFN.getService(a.serviceId); return ( ); })}
{h}
{c?.name}
{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 });