// Financial.jsx 'use strict'; const { useState, useMemo, useEffect } = React; const TRX_CATS = ['Serviço','Peças','Operacional','Pessoal','Equipamentos','Outros']; const PAY_METHODS = ['PIX','Cartão Crédito','Cartão Débito','Dinheiro','Boleto','Transferência']; const PAY_ICON = { 'PIX':'Zap', 'Cartão Crédito':'CreditCard', 'Cartão Débito':'CreditCard', 'Dinheiro':'DollarSign', 'Boleto':'FileText', 'Transferência':'RefreshCw' }; const PAY_COLOR = { 'PIX':'#10B981', 'Cartão Crédito':'#6366F1', 'Cartão Débito':'#3B82F6', 'Dinheiro':'#F59E0B', 'Boleto':'#94A3B8', 'Transferência':'#8B5CF6' }; const CAT_COLOR = { 'Serviço':'#3B82F6', 'Peças':'#F59E0B', 'Operacional':'#8B5CF6', 'Pessoal':'#EC4899', 'Equipamentos':'#10B981', 'Outros':'#94A3B8' }; // Dias entre 2 datas YYYY-MM-DD const daysBetween = (a, b) => Math.floor((new Date(b) - new Date(a)) / 86400000); function PaymentBadge({ method }) { if (!method) return ; const c = PAY_COLOR[method] || '#94A3B8'; return ( {method} ); } function DeltaChip({ delta }) { if (delta === null || delta === undefined || !isFinite(delta)) return null; const positive = delta >= 0; const color = positive ? '#059669' : '#DC2626'; const bg = positive ? '#D1FAE5' : '#FEE2E2'; return ( {positive?'+':''}{delta.toFixed(0)}% ); } function TrxModal({ open, onClose, onSave, onDelete, editTrx }) { const today = new Date().toISOString().split('T')[0]; const empty = { date: today, desc:'', category:'Serviço', type:'entrada', amount:0, status:'pago', paymentMethod:'PIX', recurring:false }; const [form, setForm] = useState(empty); const [errors, setErrors] = useState({}); useEffect(()=>{ if(open){ setForm(editTrx?{...empty,...editTrx}:{...empty}); setErrors({}); } },[open,editTrx]); const set=(k,v)=>setForm(f=>({...f,[k]:v})); const validate=()=>{ const e={}; if(!form.desc.trim())e.desc='Descrição obrigatória'; if(!form.amount||form.amount<=0)e.amount='Valor inválido'; setErrors(e); return Object.keys(e).length===0; }; const handleSave=()=>{ if(!validate())return; onSave(form); onClose(); }; return (
{['entrada','saída'].map(t=>( ))}
set('desc',e.target.value)} placeholder="Ex: OS #001 — João Silva" error={!!errors.desc}/>
set('date',e.target.value)}/> set('amount',+e.target.value)} error={!!errors.amount}/>
Cancelar {editTrx && onDelete && ( { onDelete(editTrx.id); onClose(); }} className="text-red-500 hover:bg-red-50">Excluir )} {editTrx?'Salvar':'Registrar'}
); } // ── Card de breakdown por categoria ───────────────────────────────────────── function CategoryBreakdown({ title, icon, color, items, total }) { return (

{title}

{fmtCurrency(total)}
{items.length === 0 ? (
Sem dados no mês corrente.
) : (
{items.map(({ cat, val, pct }) => { const c = CAT_COLOR[cat] || '#94A3B8'; return (
{cat} {fmtCurrency(val)} ({pct.toFixed(0)}%)
); })}
)} ); } function Financial({ transactions, workOrders, onAdd, onUpdate, onDelete, onUpdateWO, addToast }) { const [tab, setTab] = useState('movimentacoes'); const [search, setSearch] = useState(''); const [typeFilter, setTypeFilter] = useState(''); const [statusFilter, setStatusFilter] = useState(''); const [catFilter, setCatFilter] = useState(''); const [periodFilter, setPeriodFilter] = useState('6m'); const [showModal, setShowModal] = useState(false); const [editTrx, setEditTrx] = useState(null); const [confirm, setConfirm] = useState(null); const [loading, setLoading] = useState(true); useEffect(()=>{ const t=setTimeout(()=>setLoading(false),500); return()=>clearTimeout(t); },[]); const today = new Date(); const todayStr = today.toISOString().split('T')[0]; const ymCurrent = todayStr.slice(0,7); const ymPrev = (() => { const d=new Date(today); d.setMonth(d.getMonth()-1); return d.toISOString().slice(0,7); })(); const cutoff = useMemo(()=>{ const d=new Date(today); if(periodFilter==='1m') d.setMonth(d.getMonth()-1); else if(periodFilter==='3m') d.setMonth(d.getMonth()-3); else if(periodFilter==='12m') d.setMonth(d.getMonth()-12); else d.setMonth(d.getMonth()-6); return d.toISOString().split('T')[0]; },[periodFilter]); const filtered = transactions.filter(t=>{ const mP = t.date>=cutoff; const mQ = !search || t.desc?.toLowerCase().includes(search.toLowerCase()); const mT = !typeFilter || t.type===typeFilter; const mS = !statusFilter || t.status===statusFilter; const mC = !catFilter || t.category===catFilter; return mP&&mQ&&mT&&mS&&mC; }).sort((a,b)=>b.date.localeCompare(a.date)); // ─── KPIs com delta vs mês anterior ───────────────────────────────────── const stats = useMemo(()=>{ const sum = (predicate) => transactions.filter(predicate).reduce((s,t)=>s+t.amount,0); const inMes = sum(t=>t.type==='entrada'&&t.date?.startsWith(ymCurrent)&&t.status==='pago'); const outMes = sum(t=>t.type==='saída' &&t.date?.startsWith(ymCurrent)&&t.status==='pago'); const inPrev = sum(t=>t.type==='entrada'&&t.date?.startsWith(ymPrev) &&t.status==='pago'); const outPrev = sum(t=>t.type==='saída' &&t.date?.startsWith(ymPrev) &&t.status==='pago'); const pendente= sum(t=>t.status==='pendente'&&t.type==='entrada'); const overdue = transactions.filter(t=>t.status==='pendente'&&t.type==='entrada'&&daysBetween(t.date, todayStr)>30); const overdueAmount = overdue.reduce((s,t)=>s+t.amount,0); const saldo = inMes - outMes; const margem = inMes>0 ? (saldo/inMes)*100 : 0; const deltaIn = inPrev ? ((inMes-inPrev)/inPrev)*100 : null; const deltaOut = outPrev ? ((outMes-outPrev)/outPrev)*100 : null; return { inMes, outMes, saldo, margem, pendente, overdue:overdue.length, overdueAmount, deltaIn, deltaOut }; },[transactions, ymCurrent, ymPrev, todayStr]); // ─── Breakdown por categoria (mês corrente) ───────────────────────────── const breakdownCats = (type) => { const byCat = {}; transactions .filter(t=>t.type===type&&t.status==='pago'&&t.date?.startsWith(ymCurrent)) .forEach(t=>{ byCat[t.category]=(byCat[t.category]||0)+t.amount; }); const total = Object.values(byCat).reduce((s,v)=>s+v,0); const items = Object.entries(byCat) .map(([cat,val])=>({cat,val,pct: total?(val/total)*100:0})) .sort((a,b)=>b.val-a.val); return { items, total }; }; const incomeBreakdown = useMemo(()=>breakdownCats('entrada'), [transactions, ymCurrent]); const expenseBreakdown = useMemo(()=>breakdownCats('saída'), [transactions, ymCurrent]); // ─── Receivables (OSs concluídas/entregues e não marcadas como pagas) ─── const receivables = workOrders.filter(o=>o.total>0 && (o.status==='Concluída'||o.status==='Entregue') && !o.paid); const receivablesAged = receivables.map(o=>({ ...o, _days: daysBetween(o.entryDate, todayStr) })) .sort((a,b)=>b._days-a._days); const totalReceivable = receivables.reduce((s,o)=>s+(o.total||0),0); const handleSave=form=>{ if(editTrx){ onUpdate(editTrx.id,form); addToast({type:'success',msg:'Movimentação atualizada!'}); } else{ onAdd({...form,id:'TRN'+String(transactions.length+1).padStart(3,'0')}); addToast({type:'success',msg:'Movimentação registrada!'}); } }; const handleMarkPaid = t => { onUpdate(t.id, { ...t, status:'pago' }); addToast({ type:'success', msg:`Movimentação marcada como paga.` }); }; const handleReceiveOS = os => { const id = 'TRN'+String(transactions.length+1).padStart(3,'0'); onAdd({ id, date:todayStr, desc:`OS #${os.num} — ${os.clientName}`, category:'Serviço', type:'entrada', amount:os.total||0, status:'pago', paymentMethod:'PIX', recurring:false }); if (onUpdateWO) onUpdateWO(os.id, { paid:true }); addToast({ type:'success', title:'Recebimento registrado', msg:`${fmtCurrency(os.total||0)} de ${os.clientName}` }); }; const exportCSV = () => { const esc = v => `"${String(v ?? '').replace(/"/g,'""')}"`; const header = ['Data','Descrição','Categoria','Tipo','Valor (R$)','Status','Forma Pagamento','Recorrente']; const rows = filtered.map(t=>[t.date, t.desc, t.category, t.type, (t.amount||0).toFixed(2).replace('.',','), t.status, t.paymentMethod||'', t.recurring?'Sim':'Não'].map(esc).join(';')); const csv = '' + [header.map(esc).join(';'), ...rows].join('\r\n'); const blob = new Blob([csv], { type:'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `movimentacoes-${todayStr}.csv`; a.click(); URL.revokeObjectURL(url); addToast({ type:'info', msg:'CSV exportado.' }); }; // Group by month for sparklines const byMonth = useMemo(()=>{ const m={}; transactions.filter(t=>t.status==='pago').forEach(t=>{ const mo=t.date?.slice(0,7)||''; if(!m[mo])m[mo]={month:mo,entrada:0,saída:0}; m[mo][t.type]+=t.amount; }); return Object.values(m).sort((a,b)=>a.month.localeCompare(b.month)).slice(-6).map(x=>({ ...x, label: x.month ? `${x.month.slice(5,7)}/${x.month.slice(2,4)}` : '' })); },[transactions]); const isOverdue = t => t.status==='pendente' && t.type==='entrada' && daysBetween(t.date, todayStr) > 30; return (
{setEditTrx(null);setShowModal(true);}}>Nova Movimentação}/> {/* Alerta de inadimplência */} {stats.overdue > 0 && (
Atenção: {stats.overdue} movimentação(ões) vencida(s) há mais de 30 dias
Total em atraso: {fmtCurrency(stats.overdueAmount)}
)} {/* KPI cards */}
{[ { label:'Receita do Mês', value:fmtCurrency(stats.inMes), icon:'ArrowUp', bg:'#10B981', light:'#F0FDF4', text:'#065F46', delta:stats.deltaIn }, { label:'Despesa do Mês', value:fmtCurrency(stats.outMes), icon:'ArrowDown', bg:'#EF4444', light:'#FEF2F2', text:'#991B1B', delta:stats.deltaOut, deltaInverse:true }, { label:'Lucro Líquido', value:fmtCurrency(stats.saldo), icon:'Activity', bg:stats.saldo>=0?'#10B981':'#EF4444', light:stats.saldo>=0?'#F0FDF4':'#FEF2F2', text:stats.saldo>=0?'#065F46':'#991B1B', sub:`Margem ${stats.margem.toFixed(0)}%` }, { label:'A Receber', value:fmtCurrency(stats.pendente),icon:'Clock', bg:'#F59E0B', light:'#FFFBEB', text:'#92400E', sub:stats.overdue>0?`${stats.overdue} vencida(s)`:'Em dia' }, ].map((c,i)=>(
{c.label}
{c.value}
{c.delta !== undefined && c.delta !== null && ( )} {c.sub && {c.sub}} {c.delta !== null && c.delta !== undefined && vs mês ant.}
))}
{/* Mini bar chart */} {byMonth.length>0 && (

Fluxo de Caixa — Últimos 6 Meses

`R$${(v/1000).toFixed(0)}k`} formatTooltip={fmtCurrency}/>
)} {/* Breakdown por categoria — mês corrente */}
{/* Tabs */} {tab==='movimentacoes' && <>
CSV
{loading ? Array.from({length:5}).map((_,i)=>) : filtered.length===0 ? setShowModal(true)}/> :
{filtered.map(t=>{ const overdue = isOverdue(t); return ( {setEditTrx(t);setShowModal(true);}} className="border-b border-slate-50 hover:bg-slate-50 active:bg-slate-100 transition-colors cursor-pointer" style={overdue ? { background:'#FEF2F2', borderLeft:'3px solid #EF4444' } : {}}> ); })}
Data Descrição Categoria Pagamento Tipo Valor Status Ações
{fmtDate(t.date)}
{t.desc} {t.recurring && ( fixa )} {overdue && ( VENCIDA {daysBetween(t.date, todayStr)}d )}
{t.category} {t.type==='saída'?'− ':''}{fmtCurrency(t.amount)} {/* Marcar como pago — visivel sempre se pendente (acao primaria) */}
{t.status==='pendente' && ( )} {/* Desktop: editar/excluir extras */} {/* Mobile: chevron */}
} } {tab==='receber' && ( receivables.length===0 ? :
{receivablesAged.map(os=>{ const aged = os._days > 30; return ( ); })}
Nº OS Cliente Equipamento Status OS Em Aberto Valor Ações
#{os.num} {os.clientName} {os.equipment?.brand} {os.equipment?.model} {os._days}d {fmtCurrency(os.total||0)}
Total a Receber {fmtCurrency(totalReceivable)}
)}
{setShowModal(false);setEditTrx(null);}} onSave={handleSave} onDelete={(id)=>{ onDelete(id); addToast({type:'info',msg:'Movimentação excluída.'}); }} editTrx={editTrx}/> setConfirm(null)} onConfirm={()=>{onDelete(confirm);addToast({type:'info',msg:'Movimentação excluída.'});}} message="Excluir esta movimentação financeira?"/>
); } Object.assign(window, { Financial });