/* ========================================================================= Sistema KeS — Controle de Clientes Implementação do design "Sistema KeS.dc.html" (Claude Design) React + Babel locais · dados salvos no navegador (localStorage) ========================================================================= */ /* ---------- utilidades ---------- */ const S_CACHE = {}; function sx(css) { if (!css) return undefined; if (S_CACHE[css]) return S_CACHE[css]; const o = {}; String(css).split(';').forEach(d => { const i = d.indexOf(':'); if (i < 0) return; const k = d.slice(0, i).trim(); if (!k) return; o[k.replace(/-([a-z])/g, (m, c) => c.toUpperCase())] = d.slice(i + 1).trim(); }); S_CACHE[css] = o; return o; } const MESES = ['Janeiro','Fevereiro','Março','Abril','Maio','Junho','Julho','Agosto','Setembro','Outubro','Novembro','Dezembro']; const MESES_AB = ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez']; const HOJE = new Date(); const pad2 = n => String(n).padStart(2, '0'); const todayStr = () => pad2(HOJE.getDate()) + '/' + pad2(HOJE.getMonth() + 1) + '/' + HOJE.getFullYear(); const MES_NOME = MESES[HOJE.getMonth()]; const MES_LOWER = MES_NOME.toLowerCase(); const MES_AB = MESES_AB[HOJE.getMonth()].toLowerCase(); const MM = pad2(HOJE.getMonth() + 1); const ANO = HOJE.getFullYear(); const LAST6 = (() => { const a = []; for (let i = 5; i >= 0; i--) { const d = new Date(ANO, HOJE.getMonth() - i, 1); a.push(MESES_AB[d.getMonth()]); } return a; })(); /* ---------- persistência (localStorage) ---------- */ const STORE_KEY = 'kes_sistema_clientes_v1'; const SESSION_KEY = 'kes_clientes_sessao_v1'; const PERSIST = ['clients','charges','expenses','receipts','categories','nfs','nfSeq','openingBalance','flow','users','settings','empresa']; function loadSaved() { try { const raw = localStorage.getItem(STORE_KEY); return raw ? JSON.parse(raw) : null; } catch (e) { return null; } } function normalizeFlow(fl) { return LAST6.map(lbl => (fl || []).find(f => f.month === lbl) || { month: lbl, inC: 0, inV: 0, outC: 0, outV: 0 }); } /* ---------- login / permissões ---------- */ async function hashSenha(s) { const b = await crypto.subtle.digest('SHA-256', new TextEncoder().encode('kes:' + s)); return [...new Uint8Array(b)].map(x => x.toString(16).padStart(2, '0')).join(''); } const PERM_TELAS = [['dashboard', 'Visão Geral'], ['financeiro', 'Financeiro'], ['clientes', 'Clientes'], ['contratos', 'Contratos'], ['notas', 'Notas Fiscais']]; const PERM_ACOES = [['editar', 'Pode editar (cadastrar, pagar, emitir)'], ['exportar', 'Pode exportar dados'], ['admin', 'Administrador(a) — acesso total + usuários']]; const PERMS_TODAS = { admin: true, dashboard: true, financeiro: true, clientes: true, contratos: true, notas: true, editar: true, exportar: true }; const PERMS_PADRAO = { admin: false, dashboard: true, financeiro: false, clientes: true, contratos: false, notas: false, editar: false, exportar: false }; /* ---------- datas / exportação ---------- */ function parseBR(d) { const p = String(d || '').split('/'); if (p.length !== 3 || !+p[2]) return null; return new Date(+p[2], (+p[1]) - 1, +p[0]); } function isoParaBR(v) { return v ? v.split('-').reverse().join('/') : ''; } function brParaIso(v) { const p = String(v || '').split('/'); return p.length === 3 ? p[2] + '-' + pad2(p[1]) + '-' + pad2(p[0]) : ''; } function isoDaqui(dias) { const d = new Date(HOJE.getFullYear(), HOJE.getMonth(), HOJE.getDate() + dias); return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); } function baixarArquivo(nome, conteudo, mime) { const blob = new Blob([conteudo], { type: mime }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = nome; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 2000); } function csvGerar(headers, rows) { const esc = v => { v = String(v == null ? '' : v); return /[;"\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; }; return '' + [headers.join(';'), ...rows.map(r => r.map(esc).join(';'))].join('\r\n'); } const numBR = n => Number(n).toFixed(2).replace('.', ','); /* ---------- validações e máscaras (exigências Asaas / Autentique / NFS-e) ---------- */ function validaCPF(v) { const c = String(v).replace(/\D/g, ''); if (c.length !== 11 || /^(\d)\1+$/.test(c)) return false; let s = 0; for (let i = 0; i < 9; i++) s += +c[i] * (10 - i); let d = (s * 10) % 11 % 10; if (d !== +c[9]) return false; s = 0; for (let i = 0; i < 10; i++) s += +c[i] * (11 - i); d = (s * 10) % 11 % 10; return d === +c[10]; } function validaCNPJ(v) { const c = String(v).replace(/\D/g, ''); if (c.length !== 14 || /^(\d)\1+$/.test(c)) return false; const calc = (base) => { let m = base.length - 7, s = 0; for (let i = 0; i < base.length; i++) { s += +base[i] * m--; if (m < 2) m = 9; } const r = s % 11; return r < 2 ? 0 : 11 - r; }; return calc(c.slice(0, 12)) === +c[12] && calc(c.slice(0, 13)) === +c[13]; } const validaEmail = v => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(String(v).trim()); const mCPF = v => { const d = String(v).replace(/\D/g, '').slice(0, 11); return d.replace(/^(\d{3})(\d)/, '$1.$2').replace(/^(\d{3})\.(\d{3})(\d)/, '$1.$2.$3').replace(/^(\d{3})\.(\d{3})\.(\d{3})(\d)/, '$1.$2.$3-$4'); }; const mCNPJ = v => { const d = String(v).replace(/\D/g, '').slice(0, 14); return d.replace(/^(\d{2})(\d)/, '$1.$2').replace(/^(\d{2})\.(\d{3})(\d)/, '$1.$2.$3').replace(/^(\d{2})\.(\d{3})\.(\d{3})(\d)/, '$1.$2.$3/$4').replace(/\/(\d{4})(\d)/, '/$1-$2'); }; const mCEP = v => String(v).replace(/\D/g, '').slice(0, 8).replace(/^(\d{5})(\d)/, '$1-$2'); const mFone = v => { const d = String(v).replace(/\D/g, '').slice(0, 11); if (d.length <= 10) return d.replace(/^(\d{2})(\d)/, '($1) $2').replace(/(\d{4})(\d{1,4})$/, '$1-$2'); return d.replace(/^(\d{2})(\d)/, '($1) $2').replace(/^(\(\d{2}\) \d)(\d{4})(\d{4})$/, '$1 $2-$3'); }; /* dados da empresa (prestador — usados na NFS-e e nos contratos) */ const EMPRESA_PADRAO = { razao: 'KeS Assessoria de Marketing', cnpj: '', im: '', regime: 'MEI', codTrib: '', aliquota: '', cep: '', endereco: '', numero: '', bairro: '', cidade: 'Caxias do Sul', uf: 'RS', codIbge: '4305108', email: 'kesassessoria@kesassessoria.com.br', }; /* ---------- dados iniciais (exemplo — substitua pelo uso real) ---------- */ const FLOW_DEMO = [ { inC: 1, inV: 2800, outC: 0, outV: 0 }, { inC: 1, inV: 1900, outC: 1, outV: 1500 }, { inC: 1, inV: 2400, outC: 0, outV: 0 }, { inC: 1, inV: 1200, outC: 1, outV: 1800 }, { inC: 0, inV: 0, outC: 1, outV: 2200 }, { inC: 1, inV: 6500, outC: 0, outV: 0 }, ]; const SEED = { nfSeq: 142, openingBalance: 12500, flow: LAST6.map((m, i) => ({ month: m, ...FLOW_DEMO[i] })), categories: [ { id: 1, name: 'Mensalidade de clientes', type: 'entrada' }, { id: 2, name: 'Projetos avulsos', type: 'entrada' }, { id: 3, name: 'Outros recebimentos', type: 'entrada' }, { id: 4, name: 'Pró-labore / Folha', type: 'saida' }, { id: 5, name: 'Tráfego / Mídia paga', type: 'saida' }, { id: 6, name: 'Ferramentas / Software', type: 'saida' }, { id: 7, name: 'Terceirizados', type: 'saida' }, { id: 8, name: 'Impostos', type: 'saida' }, { id: 9, name: 'Escritório', type: 'saida' }, ], expenses: [ { id: 1, desc: 'Pró-labore — Ketherly', catId: 4, value: 4000, date: '05/06/2026', status: 'pago', supplier: '—', recur: 'mensal' }, { id: 2, desc: 'Meta Ads — campanhas de clientes', catId: 5, value: 3200, date: '10/06/2026', status: 'pago', supplier: 'Meta', recur: 'mensal' }, { id: 3, desc: 'Assinaturas (Canva, Adobe)', catId: 6, value: 280, date: '03/06/2026', status: 'pago', supplier: 'Adobe', recur: 'mensal' }, { id: 4, desc: 'Designer freelancer', catId: 7, value: 1500, date: '08/06/2026', status: 'pago', supplier: 'Freelancer', recur: 'unica' }, { id: 5, desc: 'Honorários contábeis', catId: 7, value: 600, date: '07/06/2026', status: 'pago', supplier: 'Contabilize', recur: 'mensal' }, { id: 6, desc: 'Internet + telefonia', catId: 9, value: 320, date: '12/06/2026', status: 'pago', supplier: 'Vivo', recur: 'mensal' }, { id: 7, desc: 'Simples Nacional (DAS)', catId: 8, value: 1850, date: '20/06/2026', status: 'pendente', supplier: 'Receita Federal', recur: 'mensal' }, ], receipts: [ { id: 1, desc: 'Projeto site institucional — Clínica Odonto', catId: 2, value: 3500, date: '06/06/2026', status: 'pago', recur: 'unica' }, ], clients: [ { id: 1, name: 'Studio Bloom Arquitetura', type: 'PJ', doc: 'CNPJ 18.402.115/0001-22', service: 'Gestão de Redes Sociais', monthly: 2800, payDay: 5, entry: '12/01/2026', status: 'ativo', contract: 'assinado', sentAt: '10/01/2026' }, { id: 2, name: 'Dra. Marina Couto', type: 'PF', doc: 'CPF 042.118.330-55', service: 'Tráfego Pago', monthly: 1900, payDay: 10, entry: '03/02/2026', status: 'ativo', contract: 'assinado', sentAt: '01/02/2026' }, { id: 3, name: 'Mercado Vista Verde', type: 'PJ', doc: 'CNPJ 27.115.880/0001-09', service: 'Gestão + Tráfego Pago', monthly: 4200, payDay: 15, entry: '20/11/2025', status: 'ativo', contract: 'assinado', sentAt: '18/11/2025' }, { id: 4, name: 'Academia Pulse', type: 'PJ', doc: 'CNPJ 33.901.244/0001-71', service: 'Gestão de Redes Sociais', monthly: 2400, payDay: 8, entry: '05/03/2026', status: 'pendente', contract: 'assinado', sentAt: '02/03/2026' }, { id: 5, name: 'João Pedro Salgados', type: 'PF', doc: 'CPF 118.554.220-08', service: 'Consultoria de Marketing', monthly: 1200, payDay: 20, entry: '14/04/2026', status: 'ativo', contract: 'assinado', sentAt: '11/04/2026' }, { id: 6, name: 'Clínica Odonto Sorriso', type: 'PJ', doc: 'CNPJ 41.220.337/0001-50', service: 'Gestão de Redes Sociais', monthly: 3100, payDay: 5, entry: '08/12/2025', status: 'ativo', contract: 'assinado', sentAt: '05/12/2025' }, { id: 7, name: 'Bella Moda Boutique', type: 'PJ', doc: 'CNPJ 22.880.114/0001-33', service: 'Tráfego Pago', monthly: 2200, payDay: 25, entry: '02/09/2025', exit: '28/05/2026', status: 'inativo', contract: 'encerrado', sentAt: '30/08/2025' }, { id: 8, name: 'Construtora Horizonte', type: 'PJ', doc: 'CNPJ 09.337.652/0001-18', service: 'Estratégia Completa', monthly: 6500, payDay: 1, entry: '25/06/2026', status: 'ativo', contract: 'aguardando', sentAt: '24/06/2026' }, ], charges: [ { id: 1, clientId: 1, status: 'pago', nfId: '000142', value: 2800, paidDate: '03/06/2026' }, { id: 2, clientId: 2, status: 'pago', nfId: null, value: 1900, paidDate: '05/06/2026' }, { id: 3, clientId: 3, status: 'pago', nfId: '000141', value: 4200, paidDate: '02/06/2026' }, { id: 6, clientId: 6, status: 'pago', nfId: null, value: 3100, paidDate: '06/06/2026' }, { id: 5, clientId: 5, status: 'pendente', nfId: null }, { id: 8, clientId: 8, status: 'pendente', nfId: null }, { id: 4, clientId: 4, status: 'atrasado', nfId: null }, ], nfs: [ { number: '000142', clientId: 1, date: '03/06/2026', value: 2800 }, { number: '000141', clientId: 3, date: '02/06/2026', value: 4200 }, { number: '000138', clientId: 6, date: '06/05/2026', value: 3100 }, { number: '000135', clientId: 2, date: '04/05/2026', value: 1900 }, ], }; /* ---------- ícones (SVG) ---------- */ const IcDash = () => (); const IcFin = () => (); const IcCli = () => (); const IcCon = () => (); const IcNf = () => (); const IcPlus = (p) => (); const IcCheck = (p) => (); const IcX = (p) => (); const IcTrash = (p) => (); const IcTrashSimple = (p) => (); const IcDoc = (p) => (); const IcSearch = () => (); const IcMoney = () => (); const IcPen = () => (); const IcShield = () => (); const IcDown = () => (); const IcUpload = () => (); const IcUsers = () => (); const IcPlug = () => (); const IcLogout = () => (); const IcLock = () => (); const IcEdit = (p) => (); /* estilos base repetidos */ const IN = 'background:#0E1318;border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:11px 13px;color:#fff;font-size:13.5px;outline:none'; const IN13 = 'background:#0E1318;border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:11px 13px;color:#fff;font-size:13px;outline:none'; const LBL = 'font-size:12px;font-weight:600;color:#B7C0CB'; const COL = 'display:flex;flex-direction:column;gap:7px'; const COL6 = 'display:flex;flex-direction:column;gap:6px'; const CARD = 'background:#131B23;border:1px solid rgba(255,255,255,.08);border-radius:16px'; const OVERLAY = 'position:fixed;inset:0;background:rgba(8,12,16,.7);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;padding:24px;animation:kesFade .2s ease both'; const BTN_CANCEL = 'flex:1;background:#28323E;color:#fff;border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:11px;font-size:13px;font-weight:600;cursor:pointer'; /* ========================================================================= Componente principal ========================================================================= */ class App extends React.Component { constructor(props) { super(props); const saved = loadSaved(); this.state = { screen: 'dashboard', clientFilter: 'todos', wizard: false, step: 1, sendState: 'idle', form: this.blankForm(), nf: null, toast: null, finTab: 'fluxo', cob: null, despesa: null, receb: null, saldoModal: null, catForm: { name: '', type: 'saida' }, search: '', nfSeq: saved ? saved.nfSeq : SEED.nfSeq, openingBalance: saved ? saved.openingBalance : SEED.openingBalance, flow: saved ? normalizeFlow(saved.flow) : SEED.flow, categories: saved ? saved.categories : SEED.categories, expenses: saved ? saved.expenses : SEED.expenses, receipts: saved ? saved.receipts : SEED.receipts, clients: saved ? saved.clients : SEED.clients, charges: saved ? saved.charges : SEED.charges, nfs: saved ? saved.nfs : SEED.nfs, users: (saved && saved.users) ? saved.users : [], settings: (saved && saved.settings) ? saved.settings : { autentiqueToken: '', asaasToken: '', nfseObs: '' }, session: localStorage.getItem(SESSION_KEY) || null, period: { from: '', to: '' }, exportOpen: false, authForm: { nome: 'Ketherly Schmidt', user: '', pass: '', pass2: '', erro: '' }, userModal: null, apiStatus: undefined, // undefined = verificando · null = servidor sem API · objeto = online intForm: { asaasChave: '', asaasAmbiente: 'sandbox', autentiqueToken: '', nfsePfx: '', nfseSenha: '', nfseAmbiente: 'homologacao' }, intTest: {}, empresa: (saved && saved.empresa) ? { ...EMPRESA_PADRAO, ...saved.empresa } : { ...EMPRESA_PADRAO }, wizErro: null, clienteModal: null, confirmModal: null, recuperar: null, // tela "esqueci minha senha" (versão publicada) pagamento: null, // { chargeId, mode:'total'|'parcial', valor } pagarDesp: null, // { expenseId, mode:'total'|'parcial', valor } bdStatus: 'verificando', // verificando | ok | offline phpPrimeiroAcesso: false, // versão publicada: banco ainda sem conta criada phpUser: null, // quem sou eu, segundo o servidor (versão publicada) }; this._dadosProntos = false; // só grava no servidor depois da sincronização inicial this._modo = null; // node | php | offline } setEmp(k, v) { this.setState(st => ({ empresa: { ...st.empresa, [k]: v } })); } componentDidMount() { this.iniciar(); } /* ===================================================================== Banco de dados — dois modos, detectados sozinho: • 'node' → servidor local (servidor.js + SQLite), no seu computador • 'php' → versão publicada (api.php + Neon), em financeiro.kesassessoria.com.br ===================================================================== */ async iniciar() { // 1) servidor local? (confere que respondeu JSON de verdade, não uma página de erro) try { const r = await fetch('/api/status'); if (r.ok) { const s = await r.json(); if (s && s.servidor) { this._modo = 'node'; await this.sincronizarDados(); this.carregarStatus(); return; } } } catch (e) { /* sem servidor Node — tenta a versão publicada */ } // 2) versão publicada (PHP + Neon) try { const r = await fetch('api.php?acao=status'); if (r.ok) { const s = await r.json(); this._modo = 'php'; this.setState({ apiStatus: null, phpPrimeiroAcesso: !!s.primeiroAcesso, bdStatus: 'ok' }); if (s.logado && s.usuario) { localStorage.setItem(SESSION_KEY, String(s.usuario.id)); this.setState({ session: String(s.usuario.id), phpUser: s.usuario }, () => this.sincronizarDados()); } else { localStorage.removeItem(SESSION_KEY); this.setState({ session: null }); } return; } } catch (e) { /* nem um nem outro */ } // 3) sem backend — segue só no navegador this._modo = 'offline'; this._dadosProntos = true; this.setState({ bdStatus: 'offline', apiStatus: null }); } urlDados() { return this._modo === 'php' ? 'api.php?acao=dados' : '/api/dados'; } async sincronizarDados() { try { const r = await fetch(this.urlDados()); if (r.status === 200) { // o banco do servidor é a fonte oficial — substitui o cache do navegador const dados = await r.json(); const novo = {}; PERSIST.forEach(k => { if (dados[k] !== undefined) novo[k] = dados[k]; }); if (novo.flow) novo.flow = normalizeFlow(novo.flow); if (novo.empresa) novo.empresa = { ...EMPRESA_PADRAO, ...novo.empresa }; this._dadosProntos = true; this.setState({ ...novo, bdStatus: 'ok' }); } else if (r.status === 401) { // publicado e ainda sem login — a tela de login cuida this._dadosProntos = false; this.setState({ bdStatus: 'ok', session: null }); } else { // servidor ok, banco ainda vazio → grava o estado atual (migra o que existia no navegador) this._dadosProntos = true; this.setState({ bdStatus: 'ok' }, () => this.salvarNoServidor(true)); } } catch (e) { // servidor simples (sem API) ou fechado → segue só no navegador this._dadosProntos = true; this.setState({ bdStatus: 'offline' }); } } salvarNoServidor(imediato) { if (!this._dadosProntos || this.state.bdStatus !== 'ok') return; if (this._modo === 'php' && !this.state.session) return; // só grava logado clearTimeout(this._svTimer); this._svTimer = setTimeout(async () => { const out = {}; PERSIST.forEach(k => { out[k] = this.state[k]; }); try { const r = await fetch(this.urlDados(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(out) }); if (!r.ok) throw new Error('gravação recusada'); } catch (e) { if (this.state.bdStatus !== 'offline') this.setState({ bdStatus: 'offline' }); } }, imediato ? 0 : 800); } /* ---------- servidor de integrações ---------- */ async carregarStatus() { try { const r = await fetch('/api/status'); if (!r.ok) throw new Error('sem api'); const s = await r.json(); this.setState(st => ({ apiStatus: s, intForm: { ...st.intForm, asaasAmbiente: s.asaas.ambiente, nfseAmbiente: s.nfse.ambiente, nfsePfx: s.nfse.pfx || '' } })); } catch (e) { this.setState({ apiStatus: null }); } } setInt(k, v) { this.setState(st => ({ intForm: { ...st.intForm, [k]: v } })); } async salvarIntegracao(campos) { try { const r = await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(campos) }); const j = await r.json(); this.flash(j.ok ? 'Configuração salva no servidor (só neste computador)' : ('Erro: ' + j.mensagem)); if (j.ok) { this.setState(st => ({ intForm: { ...st.intForm, asaasChave: '', autentiqueToken: '', nfseSenha: '' } })); this.carregarStatus(); } } catch (e) { this.flash('Servidor de integrações não respondeu'); } } async testarIntegracao(qual) { this.setState(st => ({ intTest: { ...st.intTest, [qual]: { ok: null, mensagem: 'Testando…' } } })); try { const r = await fetch('/api/' + qual + '/testar'); const j = await r.json(); this.setState(st => ({ intTest: { ...st.intTest, [qual]: j } })); } catch (e) { this.setState(st => ({ intTest: { ...st.intTest, [qual]: { ok: false, mensagem: 'Servidor de integrações não respondeu.' } } })); } } /* ---------- sessão / permissões ---------- */ currentUser() { const u = this.state.users.find(x => String(x.id) === String(this.state.session)); if (u) return u; // versão publicada: logo após o login o servidor já devolveu quem sou, antes dos dados chegarem if (this._modo === 'php' && this.state.session && this.state.phpUser) return this.state.phpUser; return null; } can(k) { const u = this.currentUser(); if (!u || !u.perms) return false; if (u.perms.admin) return true; return !!u.perms[k]; } firstAllowed(u) { const order = ['dashboard', 'financeiro', 'clientes', 'contratos', 'notas']; if (u.perms.admin) return 'dashboard'; return order.find(k => u.perms[k]) || 'dashboard'; } setAuth(k, v) { this.setState(st => ({ authForm: { ...st.authForm, [k]: v, erro: '' } })); } async criarAdmin() { const f = this.state.authForm; if (!f.user.trim()) { this.setState(st => ({ authForm: { ...st.authForm, erro: 'Informe o nome de usuário.' } })); return; } const minimo = this._modo === 'php' ? 6 : 4; if (f.pass.length < minimo) { this.setState(st => ({ authForm: { ...st.authForm, erro: 'A senha precisa ter pelo menos ' + minimo + ' caracteres.' } })); return; } if (f.pass !== f.pass2) { this.setState(st => ({ authForm: { ...st.authForm, erro: 'As senhas não conferem.' } })); return; } if (this._modo === 'php') { // versão publicada: a conta é criada no servidor try { const r = await fetch('api.php?acao=criar-admin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ nome: f.nome.trim(), user: f.user.trim().toLowerCase(), senha: f.pass }) }); const j = await r.json(); if (!r.ok || !j.ok) { this.setState(st => ({ authForm: { ...st.authForm, erro: j.mensagem || 'Não consegui criar a conta.' } })); return; } localStorage.setItem(SESSION_KEY, String(j.usuario.id)); this.setState({ session: String(j.usuario.id), phpUser: j.usuario, phpPrimeiroAcesso: false, authForm: { nome: '', user: '', pass: '', pass2: '', erro: '' } }, () => this.sincronizarDados()); this.flash('Conta da administradora criada'); } catch (e) { this.setState(st => ({ authForm: { ...st.authForm, erro: 'Servidor não respondeu.' } })); } return; } const h = await hashSenha(f.pass); const u = { id: 1, nome: f.nome.trim() || 'Administradora', user: f.user.trim().toLowerCase(), hash: h, cargo: 'Administradora', perms: { ...PERMS_TODAS } }; localStorage.setItem(SESSION_KEY, '1'); this.setState({ users: [u], session: '1', authForm: { nome: '', user: '', pass: '', pass2: '', erro: '' } }); this.flash('Conta da administradora criada'); } async entrar() { const f = this.state.authForm; if (this._modo === 'php') { // versão publicada: quem confere a senha é o servidor try { const r = await fetch('api.php?acao=login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user: f.user.trim().toLowerCase(), senha: f.pass }) }); const j = await r.json(); if (!r.ok || !j.ok) { this.setState(st => ({ authForm: { ...st.authForm, erro: j.mensagem || 'Não consegui entrar.' } })); return; } localStorage.setItem(SESSION_KEY, String(j.usuario.id)); this.setState({ session: String(j.usuario.id), phpUser: j.usuario, screen: this.firstAllowed(j.usuario), authForm: { nome: '', user: '', pass: '', pass2: '', erro: '' } }, () => this.sincronizarDados()); } catch (e) { this.setState(st => ({ authForm: { ...st.authForm, erro: 'Servidor não respondeu.' } })); } return; } const u = this.state.users.find(x => x.user === f.user.trim().toLowerCase()); if (!u) { this.setState(st => ({ authForm: { ...st.authForm, erro: 'Usuário não encontrado.' } })); return; } const h = await hashSenha(f.pass); if (h !== u.hash) { this.setState(st => ({ authForm: { ...st.authForm, erro: 'Senha incorreta.' } })); return; } localStorage.setItem(SESSION_KEY, String(u.id)); this.setState({ session: String(u.id), screen: this.firstAllowed(u), authForm: { nome: '', user: '', pass: '', pass2: '', erro: '' } }); } sair() { if (this._modo === 'php') { try { fetch('api.php?acao=sair', { method: 'POST' }); } catch (e) {} } localStorage.removeItem(SESSION_KEY); this.setState({ session: null, recuperar: null, authForm: { nome: '', user: '', pass: '', pass2: '', erro: '' } }); } /* ---------- esqueci minha senha (versão publicada) ---------- */ abrirRecuperar() { this.setState({ recuperar: { user: this.state.authForm.user || '', codigo: '', senha: '', senha2: '', erro: '', ok: '' } }); } fecharRecuperar() { this.setState({ recuperar: null }); } setRec(k, v) { this.setState(st => ({ recuperar: { ...st.recuperar, [k]: v, erro: '', ok: '' } })); } async redefinirSenha() { const r = this.state.recuperar; if (!r.user.trim()) { this.setRec('erro', 'Informe o usuário.'); return; } if (r.senha.length < 6) { this.setRec('erro', 'A nova senha precisa ter pelo menos 6 caracteres.'); return; } if (r.senha !== r.senha2) { this.setRec('erro', 'As senhas não conferem.'); return; } try { const resp = await fetch('api.php?acao=recuperar', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user: r.user.trim().toLowerCase(), codigo: r.codigo.trim(), senha: r.senha }) }); const j = await resp.json(); if (!resp.ok || !j.ok) { this.setRec('erro', j.mensagem || 'Não consegui redefinir.'); return; } this.setState(st => ({ recuperar: null, authForm: { ...st.authForm, user: r.user.trim().toLowerCase(), pass: '', erro: '' } })); this.flash('Senha redefinida! Agora é só entrar.'); } catch (e) { this.setRec('erro', 'Servidor não respondeu.'); } } /* ---------- gestão de usuários ---------- */ abrirUsuario(u) { this.setState({ userModal: u ? { id: u.id, nome: u.nome, user: u.user, cargo: u.cargo || '', pass: '', perms: { ...u.perms }, erro: '' } : { id: null, nome: '', user: '', cargo: '', pass: '', perms: { ...PERMS_PADRAO }, erro: '' } }); } setUM(k, v) { this.setState(st => ({ userModal: { ...st.userModal, [k]: v, erro: '' } })); } setUMPerm(k, v) { this.setState(st => ({ userModal: { ...st.userModal, perms: { ...st.userModal.perms, [k]: v }, erro: '' } })); } async salvarUsuario() { const m = this.state.userModal; const user = m.user.trim().toLowerCase(); if (!m.nome.trim() || !user) { this.setUM('erro', 'Preencha nome e usuário.'); return; } if (this.state.users.some(u => u.user === user && u.id !== m.id)) { this.setUM('erro', 'Já existe um usuário com esse login.'); return; } if (m.id == null && m.pass.length < 4) { this.setUM('erro', 'Defina uma senha com pelo menos 4 caracteres.'); return; } if (m.pass && m.pass.length < 4) { this.setUM('erro', 'A senha precisa ter pelo menos 4 caracteres.'); return; } const hash = m.pass ? await hashSenha(m.pass) : null; this.setState(st => { if (m.id == null) { const id = Math.max(0, ...st.users.map(u => u.id)) + 1; return { userModal: null, users: [...st.users, { id, nome: m.nome.trim(), user, cargo: m.cargo.trim(), hash, perms: m.perms }] }; } return { userModal: null, users: st.users.map(u => u.id === m.id ? { ...u, nome: m.nome.trim(), user, cargo: m.cargo.trim(), hash: hash || u.hash, perms: m.perms } : u) }; }); this.flash(m.id == null ? 'Usuário criado' : 'Usuário atualizado'); } removerUsuario(id) { const me = this.currentUser(); if (me && me.id === id) { this.flash('Você não pode excluir o próprio usuário'); return; } const admins = this.state.users.filter(u => u.perms.admin); const alvo = this.state.users.find(u => u.id === id); if (alvo && alvo.perms.admin && admins.length <= 1) { this.flash('É preciso manter pelo menos um administrador'); return; } this.setState(st => ({ users: st.users.filter(u => u.id !== id) })); this.flash('Usuário removido'); } /* ---------- filtro por data ---------- */ setPeriod(k, v) { this.setState(st => ({ period: { ...st.period, [k]: v } })); } inPeriod(dstr) { const { from, to } = this.state.period; if (!from && !to) return true; const d = parseBR(dstr); if (!d) return true; if (from && d < new Date(from + 'T00:00:00')) return false; if (to && d > new Date(to + 'T23:59:59')) return false; return true; } temFiltro() { return !!(this.state.period.from || this.state.period.to); } periodLabel() { const { from, to } = this.state.period; if (!from && !to) return MES_NOME + '/' + ANO; return (from ? isoParaBR(from) : 'início') + ' a ' + (to ? isoParaBR(to) : 'hoje'); } /* ---------- exportação ---------- */ setSetting(k, v) { this.setState(st => ({ settings: { ...st.settings, [k]: v } })); } exportar(tipo) { const st = this.state; const cv = (c) => c.value != null ? c.value : (this.client(c.clientId).monthly || 0); const catName = (id) => (st.categories.find(c => c.id === id) || {}).name || '—'; const hoje = todayStr().replace(/\//g, '-'); let headers, rows, nome; if (tipo === 'clientes') { nome = 'clientes'; headers = ['Nome', 'Tipo', 'Documento', 'Serviço', 'Mensalidade (R$)', 'Dia venc.', 'Entrada', 'Status', 'Contrato']; rows = st.clients.filter(c => this.inPeriod(c.entry)).map(c => [c.name, c.type, c.doc, c.service, numBR(c.monthly), c.payDay, c.entry, c.status, c.contract]); } else if (tipo === 'cobrancas') { nome = 'cobrancas'; headers = ['Cliente', 'Descrição', 'Valor (R$)', 'Pago (R$)', 'Falta (R$)', 'Status', 'Pago em', 'NFS-e']; rows = st.charges.filter(c => this.chargeStatus(c) !== 'pago' || this.inPeriod(c.paidDate)).map(c => { const cl = this.client(c.clientId); return [cl.name || '—', c.desc || cl.service || '—', numBR(cv(c)), numBR(this.chargePaid(c)), numBR(this.chargeRemaining(c)), this.chargeStatus(c), c.paidDate || '—', c.nfId || '—']; }); } else if (tipo === 'despesas') { nome = 'despesas'; headers = ['Descrição', 'Categoria', 'Fornecedor', 'Data', 'Valor (R$)', 'Status', 'Recorrência']; rows = st.expenses.filter(e => this.inPeriod(e.date)).map(e => [e.desc, catName(e.catId), e.supplier, e.date, numBR(e.value), e.status, e.recur]); } else if (tipo === 'recebimentos') { nome = 'recebimentos'; headers = ['Descrição', 'Categoria', 'Data', 'Valor (R$)', 'Recebido (R$)', 'Falta (R$)', 'Recorrência']; rows = st.receipts.filter(r => this.inPeriod(r.date)).map(r => [r.desc, catName(r.catId), r.date, numBR(r.value), numBR(this.receiptPaid(r)), numBR(this.receiptRemaining(r)), r.recur]); } else if (tipo === 'extrato') { nome = 'extrato'; headers = ['Data', 'Descrição', 'Categoria', 'Tipo', 'Valor (R$)', 'Saldo (R$)']; rows = this.ledger().filter(l => this.inPeriod(l.date)).map(l => [l.date, l.desc, l.cat, l.type, (l.type === 'entrada' ? '' : '-') + numBR(l.value), numBR(l.balance)]); } else if (tipo === 'notas') { nome = 'notas-fiscais'; headers = ['Número', 'Tomador', 'Emissão', 'Valor (R$)']; rows = st.nfs.filter(n => this.inPeriod(n.date)).map(n => [n.number, this.client(n.clientId).name || '—', n.date, numBR(n.value)]); } else if (tipo === 'backup') { const out = {}; PERSIST.forEach(k => { out[k] = st[k]; }); baixarArquivo('backup-controle-clientes-' + hoje + '.json', JSON.stringify(out, null, 2), 'application/json'); this.flash('Backup completo exportado'); return; } else { return; } baixarArquivo(nome + '-' + hoje + '.csv', csvGerar(headers, rows), 'text/csv;charset=utf-8'); this.flash('Arquivo ' + nome + '.csv exportado (' + rows.length + ' linhas)'); } componentDidUpdate() { // navegador vira só um cache local — o banco oficial fica no servidor (dados/dados.json) const out = {}; PERSIST.forEach(k => { out[k] = this.state[k]; }); try { localStorage.setItem(STORE_KEY, JSON.stringify(out)); } catch (e) {} this.salvarNoServidor(); } /* ---------- helpers ---------- */ fmt(n) { return 'R$ ' + Number(n).toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } initials(name) { const p = String(name).replace(/^(Dra?\.|Dr\.)\s*/, '').trim().split(/\s+/); return ((p[0] || '')[0] || '') + ((p[1] || '')[0] || ''); } client(id) { return this.state.clients.find(c => c.id === id) || {}; } parseV(v) { return parseFloat(String(v).replace(/\./g, '').replace(',', '.')) || 0; } /* ---------- navegação / formulário ---------- */ go(s) { this.setState({ screen: s }); } blankForm() { return { type: 'PJ', name: '', doc: '', phone: '', email: '', cep: '', endereco: '', numero: '', bairro: '', cidade: 'Caxias do Sul', uf: 'RS', service: 'Gestão de Redes Sociais', monthly: '', payment: 'PIX', payDay: '5', warranty: 'Sem fidelidade', template: 'gestao', signerEmail: '', whats: true, sendNow: true, extraSigners: [], cobrancas: [{ id: 1, desc: 'Mensalidade', value: '', recur: 'Mensal recorrente', due: '' }] }; } startNew() { if (!this.can('editar')) return; this.setState({ wizard: true, step: 1, sendState: 'idle', form: this.blankForm() }); } closeWizard() { this.setState({ wizard: false }); } setF(k, v) { this.setState(s => ({ form: { ...s.form, [k]: v } })); } addCobranca() { this.setState(st => { const id = Math.max(0, ...st.form.cobrancas.map(c => c.id)) + 1; return { form: { ...st.form, cobrancas: [...st.form.cobrancas, { id, desc: '', value: '', recur: 'Parcela única', due: '' }] } }; }); } removeCobranca(id) { this.setState(st => ({ form: { ...st.form, cobrancas: st.form.cobrancas.length > 1 ? st.form.cobrancas.filter(c => c.id !== id) : st.form.cobrancas } })); } updateCobranca(id, k, v) { this.setState(st => ({ form: { ...st.form, cobrancas: st.form.cobrancas.map(c => c.id === id ? { ...c, [k]: v } : c) } })); } setSendNow(v) { this.setF('sendNow', v); } addSigner() { this.setState(st => { const id = Math.max(0, ...st.form.extraSigners.map(s => s.id || 0)) + 1; return { form: { ...st.form, extraSigners: [...st.form.extraSigners, { id, name: '', email: '' }] } }; }); } removeSigner(id) { this.setState(st => ({ form: { ...st.form, extraSigners: st.form.extraSigners.filter(s => s.id !== id) } })); } updateSigner(id, k, v) { this.setState(st => ({ form: { ...st.form, extraSigners: st.form.extraSigners.map(s => s.id === id ? { ...s, [k]: v } : s) } })); } sendLater(id) { if (!this.can('editar')) return; const today = todayStr(); this.setState(st => ({ clients: st.clients.map(c => c.id === id ? { ...c, contract: 'aguardando', sentAt: today } : c) })); this.flash('Contrato enviado pelo Autentique'); } /* ---------- financeiro ---------- */ setFinTab(t) { this.setState({ finTab: t }); } openCob(edit) { if (!this.can('editar')) return; if (edit) { const recur = edit.recur || 'unica'; this.setState({ cob: { stage: 'form', id: edit.id, clientId: String(edit.clientId), desc: edit.desc || '', value: String(edit.value != null ? edit.value : (this.client(edit.clientId).monthly || '')).replace('.', ','), due: edit.dueDate ? brParaIso(edit.dueDate) : isoDaqui(3), payment: edit.payment || 'PIX', recur, installments: String(edit.installments || '12'), status: edit.status } }); } else { this.setState({ cob: { stage: 'form', clientId: '', desc: 'Mensalidade', value: '', due: isoDaqui(3), payment: 'PIX', recur: 'mensal', installments: '12' } }); } } closeCob() { this.setState({ cob: null }); } setCob(k, v) { this.setState(st => ({ cob: { ...st.cob, [k]: v } })); } createCob() { const c = this.state.cob; if (!c.clientId || this.parseV(c.value) <= 0 || !c.due) return; if (c.id) { // edição de cobrança existente — salva direto this.setState(st => ({ cob: null, charges: st.charges.map(ch => ch.id === c.id ? { ...ch, clientId: parseInt(c.clientId), value: this.parseV(c.value), desc: c.desc, due: parseInt(c.due.split('-')[2]), dueDate: isoParaBR(c.due), payment: c.payment, recur: c.recur } : ch) })); this.flash('Cobrança atualizada'); return; } this.setState(st => ({ cob: { ...st.cob, stage: 'loading' } })); setTimeout(() => { const parcelas = c.recur === 'parcelada' ? (parseInt(c.installments) || 1) : null; const cdesc = c.recur === 'parcelada' ? (c.desc + ' (parcela 1/' + parcelas + ')') : c.desc; this.setState(st => ({ cob: { ...st.cob, stage: 'done' }, charges: [...st.charges, { id: Date.now(), clientId: parseInt(c.clientId), status: 'pendente', nfId: null, value: this.parseV(c.value), desc: cdesc, due: parseInt(c.due.split('-')[2]), dueDate: isoParaBR(c.due), payment: c.payment, recur: c.recur, installments: parcelas }] })); this.flash(c.recur === 'unica' ? 'Cobrança única criada no Asaas' : c.recur === 'parcelada' ? ('Cobrança em ' + parcelas + 'x criada no Asaas') : 'Assinatura mensal criada no Asaas'); }, 1500); } deleteCharge(id) { if (!this.can('editar')) return; this.setState(st => ({ charges: st.charges.filter(c => c.id !== id) })); this.flash('Cobrança excluída'); } openDespesa(edit) { if (!this.can('editar')) return; this.setState({ despesa: edit ? { id: edit.id, desc: edit.desc, catId: String(edit.catId), value: String(edit.value).replace('.', ','), date: edit.date, supplier: edit.supplier === '—' ? '' : edit.supplier, status: edit.status, recur: edit.recur } : { desc: '', catId: '4', value: '', date: todayStr(), supplier: '', status: 'pago', recur: 'unica' } }); } closeDespesa() { this.setState({ despesa: null }); } setDespesa(k, v) { this.setState(st => ({ despesa: { ...st.despesa, [k]: v } })); } saveDespesa() { const d = this.state.despesa; if (this.parseV(d.value) <= 0) return; if (d.id) { this.setState(st => ({ despesa: null, expenses: st.expenses.map(e => e.id === d.id ? { ...e, desc: d.desc || 'Despesa', catId: parseInt(d.catId), value: this.parseV(d.value), date: d.date, status: d.status, supplier: d.supplier || '—', recur: d.recur } : e) })); this.flash('Despesa atualizada'); return; } this.setState(st => ({ despesa: null, expenses: [{ id: Date.now(), desc: d.desc || 'Despesa', catId: parseInt(d.catId), value: this.parseV(d.value), date: d.date, status: d.status, supplier: d.supplier || '—', recur: d.recur }, ...st.expenses] })); this.flash(d.recur === 'mensal' ? 'Despesa recorrente registrada' : (d.status === 'pago' ? 'Despesa registrada — saída lançada no fluxo' : 'Despesa registrada')); } deleteExpense(id) { if (!this.can('editar')) return; this.setState(st => ({ expenses: st.expenses.filter(e => e.id !== id) })); this.flash('Despesa excluída'); } openPagarDespesa(e) { if (!this.can('editar')) return; this.setState({ pagarDesp: { expenseId: e.id, mode: 'total', valor: numBR(this.expenseRemaining(e)) } }); } closePagarDesp() { this.setState({ pagarDesp: null }); } setPagarDesp(k, v) { this.setState(st => ({ pagarDesp: { ...st.pagarDesp, [k]: v } })); } registrarPagamentoDespesa() { const pg = this.state.pagarDesp; if (!pg) return; const e = this.state.expenses.find(x => x.id === pg.expenseId); if (!e) return; const total = e.value; const jaPago = this.expensePaid(e); let novoPago; if (pg.mode === 'total') novoPago = total; else { const add = this.parseV(pg.valor); if (add <= 0) return; novoPago = Math.min(jaPago + add, total); } const quitado = novoPago >= total; this.setState(st => ({ pagarDesp: null, expenses: st.expenses.map(x => x.id === pg.expenseId ? { ...x, paid: novoPago, status: quitado ? 'pago' : 'pendente', date: todayStr() } : x) })); this.flash(quitado ? 'Despesa paga — saída lançada no fluxo' : ('Pagamento parcial de ' + this.fmt(novoPago - jaPago) + ' — falta ' + this.fmt(total - novoPago))); } markExpensePaid(id) { const e = this.state.expenses.find(x => x.id === id); if (e) this.openPagarDespesa(e); } openReceb(edit) { if (!this.can('editar')) return; if (edit) { const pago = edit.paid != null ? edit.paid : edit.value; const parcial = pago < edit.value; this.setState({ receb: { id: edit.id, desc: edit.desc, catId: String(edit.catId), value: String(edit.value).replace('.', ','), recebido: String(pago).replace('.', ','), parcial, date: edit.date, recur: edit.recur } }); } else { this.setState({ receb: { desc: '', catId: '2', value: '', recebido: '', parcial: false, date: todayStr(), recur: 'unica' } }); } } closeReceb() { this.setState({ receb: null }); } setReceb(k, v) { this.setState(st => ({ receb: { ...st.receb, [k]: v } })); } saveReceb() { const r = this.state.receb; const total = this.parseV(r.value); if (total <= 0) return; const pago = r.parcial ? Math.min(this.parseV(r.recebido), total) : total; const status = pago >= total ? 'pago' : (pago > 0 ? 'parcial' : 'pendente'); if (r.id) { this.setState(st => ({ receb: null, receipts: st.receipts.map(x => x.id === r.id ? { ...x, desc: r.desc || 'Recebimento', catId: parseInt(r.catId), value: total, paid: pago, status, date: r.date, recur: r.recur } : x) })); this.flash('Recebimento atualizado'); return; } this.setState(st => ({ receb: null, receipts: [{ id: Date.now(), desc: r.desc || 'Recebimento', catId: parseInt(r.catId), value: total, paid: pago, status, date: r.date, recur: r.recur }, ...st.receipts] })); this.flash(r.parcial && pago < total ? ('Recebimento parcial de ' + this.fmt(pago) + ' — falta ' + this.fmt(total - pago)) : 'Recebimento registrado — entrada lançada no fluxo'); } completarReceb(id) { if (!this.can('editar')) return; this.setState(st => ({ receipts: st.receipts.map(r => r.id === id ? { ...r, paid: r.value, status: 'pago' } : r) })); this.flash('Restante recebido — recebimento quitado'); } deleteReceipt(id) { if (!this.can('editar')) return; this.setState(st => ({ receipts: st.receipts.filter(r => r.id !== id) })); this.flash('Recebimento excluído'); } deleteNf(number) { if (!this.can('editar')) return; this.setState(st => ({ nfs: st.nfs.filter(n => n.number !== number), charges: st.charges.map(c => c.nfId === number ? { ...c, nfId: null } : c) })); this.flash('Nota fiscal excluída'); } /* ---------- cliente: editar / excluir ---------- */ openCliente(edit) { if (!this.can('editar')) return; const docDig = String(edit.doc || '').replace(/\D/g, ''); this.setState({ clienteModal: { id: edit.id, type: edit.type || 'PJ', name: edit.name || '', doc: docDig, phone: edit.phone || '', email: edit.email || '', cep: edit.cep || '', endereco: edit.endereco || '', numero: edit.numero || '', bairro: edit.bairro || '', cidade: edit.cidade || 'Caxias do Sul', uf: edit.uf || 'RS', service: edit.service || '', monthly: String(edit.monthly != null ? edit.monthly : '').replace('.', ','), payDay: String(edit.payDay || '5'), status: edit.status || 'ativo', erro: '' } }); } setClienteModal(k, v) { this.setState(st => ({ clienteModal: { ...st.clienteModal, [k]: v, erro: '' } })); } saveCliente() { const m = this.state.clienteModal; const isPJ = m.type === 'PJ'; if (!m.name.trim()) { this.setClienteModal('erro', 'Informe o nome/razão social.'); return; } if (m.doc && (isPJ ? !validaCNPJ(m.doc) : !validaCPF(m.doc))) { this.setClienteModal('erro', (isPJ ? 'CNPJ' : 'CPF') + ' inválido.'); return; } if (m.email && !validaEmail(m.email)) { this.setClienteModal('erro', 'E-mail inválido.'); return; } this.setState(st => ({ clienteModal: null, clients: st.clients.map(c => c.id === m.id ? { ...c, type: m.type, name: m.name.trim(), doc: (isPJ ? 'CNPJ ' : 'CPF ') + (m.doc || '—'), phone: m.phone, email: m.email, cep: m.cep, endereco: m.endereco, numero: m.numero, bairro: m.bairro, cidade: m.cidade, uf: m.uf, service: m.service, monthly: this.parseV(m.monthly), payDay: parseInt(m.payDay) || 5, status: m.status } : c) })); this.flash('Cliente atualizado'); } deleteCliente(id) { if (!this.can('editar')) return; this.setState(st => ({ clients: st.clients.filter(c => c.id !== id), charges: st.charges.filter(c => c.clientId !== id) })); this.flash('Cliente e suas cobranças foram excluídos'); } setCat(k, v) { this.setState(st => ({ catForm: { ...st.catForm, [k]: v } })); } addCategory() { if (!this.can('editar')) return; const c = this.state.catForm; if (!c.name.trim()) return; if (c.id) { this.setState(st => ({ catForm: { name: '', type: c.type }, categories: st.categories.map(x => x.id === c.id ? { ...x, name: c.name.trim(), type: c.type } : x) })); this.flash('Categoria atualizada'); return; } this.setState(st => ({ catForm: { name: '', type: c.type }, categories: [...st.categories, { id: Date.now(), name: c.name.trim(), type: c.type }] })); this.flash('Categoria criada'); } editCategory(cat) { if (!this.can('editar')) return; this.setState({ catForm: { id: cat.id, name: cat.name, type: cat.type } }); } removeCategory(id) { if (!this.can('editar')) return; this.setState(st => ({ categories: st.categories.filter(c => c.id !== id), catForm: st.catForm.id === id ? { name: '', type: st.catForm.type } : st.catForm })); this.flash('Categoria excluída'); } openSaldo() { if (!this.can('editar')) return; this.setState({ saldoModal: { value: this.state.openingBalance.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } }); } closeSaldo() { this.setState({ saldoModal: null }); } setSaldo(v) { this.setState(st => ({ saldoModal: { ...st.saldoModal, value: v } })); } saveSaldo() { const v = parseFloat(String(this.state.saldoModal.value).replace(/\./g, '').replace(',', '.')); if (isNaN(v)) return; this.setState({ openingBalance: v, saldoModal: null }); this.flash('Saldo inicial de caixa ajustado'); } /* ---------- wizard ---------- */ nextStep() { const s = this.state.step; const f = this.state.form; if (s === 1) { // exigências do Asaas (name + cpfCnpj) e da NFS-e (documento do tomador) const digitos = f.doc.replace(/\D/g, ''); if (!f.name.trim()) { this.setState({ wizErro: 'Informe ' + (f.type === 'PJ' ? 'a razão social' : 'o nome completo') + ' — obrigatório no Asaas e no contrato.' }); return; } if (!digitos) { this.setState({ wizErro: 'Informe o ' + (f.type === 'PJ' ? 'CNPJ' : 'CPF') + ' — o Asaas e a nota fiscal exigem o documento do cliente.' }); return; } if (f.type === 'PJ' ? !validaCNPJ(f.doc) : !validaCPF(f.doc)) { this.setState({ wizErro: (f.type === 'PJ' ? 'CNPJ' : 'CPF') + ' inválido — confira os números digitados.' }); return; } if (f.email && !validaEmail(f.email)) { this.setState({ wizErro: 'E-mail inválido — confira o endereço digitado.' }); return; } this.setState({ step: 2, wizErro: null }); return; } if (s === 2) { const temCobranca = this.parseV(f.monthly) > 0 || f.cobrancas.some(c => this.parseV(c.value) > 0); if (!temCobranca) { this.setState({ wizErro: 'Informe o valor mensal ou pelo menos uma cobrança com valor — é o que será criado no Asaas.' }); return; } this.setState({ step: 3, wizErro: null }); return; } if (s === 3) { // Autentique envia por e-mail: sugere o e-mail do cadastro como signatário this.setState(st => ({ step: 4, wizErro: null, form: { ...st.form, signerEmail: st.form.signerEmail || (validaEmail(st.form.email) ? st.form.email : '') } })); return; } if (s === 4) { if (f.sendNow && !validaEmail(f.signerEmail)) { this.setState({ wizErro: 'Informe um e-mail válido para a assinatura — o Autentique envia o contrato por e-mail.' }); return; } this.setState({ wizErro: null }); this.submit(); } } prevStep() { if (this.state.step > 1) this.setState(st => ({ step: st.step - 1, wizErro: null })); } submit() { if (this.state.form.sendNow) { this.setState({ sendState: 'sending' }); setTimeout(() => this.finalize(), 1900); } else { this.finalize(); } } finalize() { const f = this.state.form; const newId = Math.max(0, ...this.state.clients.map(c => c.id)) + 1; const monthlyNum = this.parseV(f.monthly); const today = todayStr(); const newClient = { id: newId, name: f.name || 'Novo Cliente', type: f.type, doc: (f.type === 'PJ' ? 'CNPJ ' : 'CPF ') + (f.doc || '—'), phone: f.phone, email: f.email, cep: f.cep, endereco: f.endereco, numero: f.numero, bairro: f.bairro, cidade: f.cidade, uf: f.uf, service: f.service, monthly: monthlyNum, payDay: parseInt(f.payDay), entry: today, status: 'ativo', contract: f.sendNow ? 'aguardando' : 'rascunho', sentAt: f.sendNow ? today : '—' }; const cobs = f.cobrancas.filter(c => this.parseV(c.value) > 0); const list = cobs.length ? cobs : [{ desc: f.service, value: String(f.monthly), due: '' }]; const newCharges = list.map((c, i) => ({ id: 1000 + newId * 10 + i, clientId: newId, status: 'pendente', nfId: null, value: this.parseV(c.value), desc: c.desc || f.service, due: parseInt(f.payDay), dueDate: c.due ? isoParaBR(c.due) : null })); this.setState(st => ({ sendState: 'done', clients: [newClient, ...st.clients], charges: [...st.charges, ...newCharges], flow: st.flow.map((m, i) => i === st.flow.length - 1 ? { ...m, inC: m.inC + 1, inV: m.inV + monthlyNum } : m) })); this.flash(newCharges.length + ' cobrança(s) criada(s) no Asaas'); } goConClose() { this.setState({ wizard: false, screen: 'contratos' }); } /* ---------- valores pagos / recebidos (parcial) ---------- */ cval(c) { return c.value != null ? c.value : (this.client(c.clientId).monthly || 0); } chargePaid(c) { return c.paid != null ? c.paid : (c.status === 'pago' ? this.cval(c) : 0); } chargeRemaining(c) { return Math.max(0, this.cval(c) - this.chargePaid(c)); } chargeStatus(c) { const p = this.chargePaid(c), t = this.cval(c); if (t > 0 && p >= t) return 'pago'; if (p > 0) return 'parcial'; return c.status === 'atrasado' ? 'atrasado' : 'pendente'; } receiptPaid(r) { return r.paid != null ? r.paid : r.value; } receiptRemaining(r) { return Math.max(0, r.value - this.receiptPaid(r)); } expensePaid(e) { return e.paid != null ? e.paid : (e.status === 'pago' ? e.value : 0); } expenseRemaining(e) { return Math.max(0, e.value - this.expensePaid(e)); } expenseStatus(e) { const p = this.expensePaid(e), t = e.value; if (t > 0 && p >= t) return 'pago'; if (p > 0) return 'parcial'; return 'pendente'; } /* ---------- cobranças / NF ---------- */ openPagamento(charge) { if (!this.can('editar')) return; const falta = this.chargeRemaining(charge); this.setState({ pagamento: { chargeId: charge.id, mode: 'total', valor: numBR(falta).replace(',00', ',00') } }); } closePagamento() { this.setState({ pagamento: null }); } setPagamento(k, v) { this.setState(st => ({ pagamento: { ...st.pagamento, [k]: v } })); } registrarPagamento() { const pg = this.state.pagamento; if (!pg) return; const ch = this.state.charges.find(c => c.id === pg.chargeId); if (!ch) return; const total = this.cval(ch); const jaPago = this.chargePaid(ch); let novoPago; if (pg.mode === 'total') novoPago = total; else { const add = this.parseV(pg.valor); if (add <= 0) return; novoPago = Math.min(jaPago + add, total); } const quitado = novoPago >= total; this.setState(st => ({ pagamento: null, charges: st.charges.map(c => c.id === pg.chargeId ? { ...c, paid: novoPago, paidDate: todayStr(), status: quitado ? 'pago' : (c.status === 'atrasado' ? 'atrasado' : 'pendente') } : c) })); this.flash(quitado ? 'Pagamento total registrado — entrada lançada no fluxo' : ('Pagamento parcial de ' + this.fmt(novoPago - jaPago) + ' — falta ' + this.fmt(total - novoPago))); } pay(chargeId) { const c = this.state.charges.find(x => x.id === chargeId); if (c) this.openPagamento(c); } openNf(chargeId) { if (!this.can('editar')) return; this.setState({ nf: { chargeId, stage: 'confirm' } }); } closeNf() { this.setState({ nf: null }); } confirmNf() { this.setState(st => ({ nf: { ...st.nf, stage: 'loading' } })); setTimeout(() => { const num = String(this.state.nfSeq + 1).padStart(6, '0'); this.setState(st => { const ch = st.charges.find(c => c.id === st.nf.chargeId); const cl = this.client(ch.clientId); return { nfSeq: st.nfSeq + 1, nf: { ...st.nf, stage: 'done', number: num }, charges: st.charges.map(c => c.id === st.nf.chargeId ? { ...c, nfId: num } : c), nfs: [{ number: num, clientId: ch.clientId, date: todayStr(), value: (ch.value != null ? ch.value : cl.monthly) }, ...st.nfs], }; }); }, 1800); } flash(msg) { this.setState({ toast: msg }); clearTimeout(this._t); this._t = setTimeout(() => this.setState({ toast: null }), 2600); } confirmarExclusao(msg, onYes) { if (!this.can('editar')) return; this.setState({ confirmModal: { msg, onYes } }); } navStyle(key) { const active = this.state.screen === key; return { display: 'flex', alignItems: 'center', gap: '12px', padding: '11px 12px', borderRadius: '10px', border: 'none', cursor: 'pointer', fontSize: '13.5px', fontWeight: active ? '700' : '500', fontFamily: 'inherit', textAlign: 'left', width: '100%', transition: 'all .14s', background: active ? 'rgba(224,60,49,.12)' : 'transparent', color: active ? '#EB6258' : '#B7C0CB', boxShadow: active ? 'inset 2px 0 0 #E03C31' : 'none' }; } /* ===================================================================== RENDER ===================================================================== */ render() { const st = this.state; const cv = (c) => c.value != null ? c.value : this.client(c.clientId).monthly; this._cv = cv; if (st.bdStatus === 'verificando' && st.users.length === 0 && !this._modo) { return (
Carregando o banco de dados…
); } const me = this.currentUser(); if (!me) return this.renderAuth(); const telas = ['dashboard', 'financeiro', 'clientes', 'contratos', 'notas']; this._screen = (telas.includes(st.screen) && !this.can(st.screen)) ? this.firstAllowed(me) : st.screen; const pages = { dashboard: ['Painel', 'Visão Geral'], financeiro: ['Financeiro', 'Painel Financeiro'], clientes: ['Clientes', 'Entrada e Saída de Clientes'], contratos: ['Documentos', 'Contratos · Autentique'], notas: ['Fiscal', 'Notas Fiscais · NFS-e'], usuarios: ['Acesso', 'Usuários e Permissões'], integracoes: ['Conexões', 'Integrações'] }; const pg = pages[this._screen] || pages.dashboard; return (
{this.renderSidebar()}
{pg[0]}
{pg[1]}
{st.apiStatus && st.apiStatus.ambiente === 'teste' && ( AMBIENTE DE TESTE )}
{this.can('clientes') && (
{ const v = e.target.value; this.setState({ search: v, screen: v ? 'clientes' : st.screen }); }} placeholder="Buscar cliente…" style={sx('background:transparent;border:none;outline:none;color:#fff;font-size:13px;width:130px')} />
)}
De this.setPeriod('from', e.target.value)} style={sx('background:#0E1318;border:1px solid rgba(255,255,255,.09);border-radius:8px;padding:5px 7px;color:#fff;font-size:12px;outline:none;color-scheme:dark;font-family:inherit')} /> Até this.setPeriod('to', e.target.value)} style={sx('background:#0E1318;border:1px solid rgba(255,255,255,.09);border-radius:8px;padding:5px 7px;color:#fff;font-size:12px;outline:none;color-scheme:dark;font-family:inherit')} /> {this.temFiltro() && ( )}
{this.can('exportar') && ( )}
{st.bdStatus === 'offline' && (
⚠ Sem conexão com o banco de dados local — os dados estão sendo salvos só neste navegador. Abra o sistema pelo atalho "Abrir Controle de Clientes.bat" para gravar no banco.
)}
{this._screen === 'dashboard' && this.renderDash()} {this._screen === 'financeiro' && this.renderFin()} {this._screen === 'clientes' && this.renderCli()} {this._screen === 'contratos' && this.renderCon()} {this._screen === 'notas' && this.renderNf()} {this._screen === 'usuarios' && this.can('admin') && this.renderUsers()} {this._screen === 'integracoes' && this.can('admin') && this.renderIntegracoes()}
{st.wizard && this.renderWizard()} {!!st.nf && this.renderNfModal()} {!!st.cob && this.renderCobModal()} {!!st.despesa && this.renderDespesaModal()} {!!st.receb && this.renderRecebModal()} {!!st.saldoModal && this.renderSaldoModal()} {st.exportOpen && this.renderExportModal()} {!!st.userModal && this.renderUserModal()} {!!st.clienteModal && this.renderClienteModal()} {!!st.pagamento && this.renderPagamentoModal()} {!!st.pagarDesp && this.renderPagarDespModal()} {!!st.confirmModal && (
{ if (e.target === e.currentTarget) this.setState({ confirmModal: null }); }}>
Confirmar exclusão
{st.confirmModal.msg}
)} {!!st.toast && (
{st.toast}
)}
); } /* ---------- barra lateral ---------- */ renderSidebar() { return ( ); } /* ---------- Visão Geral ---------- */ renderDash() { const st = this.state; const cv = this._cv; const active = st.clients.filter(c => c.status !== 'inativo'); const mrr = active.reduce((s, c) => s + c.monthly, 0); const dot = (c) => sx('width:9px;height:9px;border-radius:9px;background:' + c); const dashKpis = [ { label: 'Clientes ativos', value: String(active.length), delta: '+2 este mês', dot: dot('#2BB673'), deltaStyle: 'font-size:11.5px;color:#2BB673;font-weight:600;margin-top:6px' }, { label: 'Receita recorrente (MRR)', value: this.fmt(mrr), delta: '+8,4% vs. mês anterior', dot: dot('#E03C31'), deltaStyle: 'font-size:11.5px;color:#2BB673;font-weight:600;margin-top:6px' }, { label: 'NFS-e emitidas (' + (this.temFiltro() ? 'período' : MES_AB) + ')', value: String(this.temFiltro() ? st.nfs.filter(n => this.inPeriod(n.date)).length : st.nfs.filter(n => n.date.includes('/' + MM + '/')).length), delta: this.temFiltro() ? this.periodLabel() : 'no mês corrente', dot: dot('#3E8EDE'), deltaStyle: 'font-size:11.5px;color:#7C8794;font-weight:500;margin-top:6px' }, { label: 'Contratos pendentes', value: String(st.clients.filter(c => c.contract === 'aguardando').length), delta: 'aguardando assinatura', dot: dot('#F2A93B'), deltaStyle: 'font-size:11.5px;color:#F2A93B;font-weight:600;margin-top:6px' }, ]; const chartDemo = [18.2, 19.5, 21.0, 22.4, 23.1]; const chartData = LAST6.map((m, i) => i < 5 ? [m, chartDemo[i]] : [m, mrr / 1000]); const maxv = Math.max(...chartData.map(d => d[1])); const chartBars = chartData.map((d, i) => { const last = i === chartData.length - 1; return { month: d[0], top: 'R$' + d[1].toFixed(1).replace('.', ','), barStyle: `width:100%;max-width:46px;border-radius:7px 7px 0 0;height:${(d[1] / maxv) * 100}%;background:${last ? '#E03C31' : '#28323E'};box-shadow:${last ? '0 10px 30px rgba(224,60,49,.35)' : 'none'};transition:height .4s` }; }); const actIcon = (c) => 'width:30px;height:30px;border-radius:8px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:' + c + '22'; const actDot = (c) => 'width:8px;height:8px;border-radius:8px;background:' + c; const activity = [ { iconWrap: actIcon('#2BB673'), dot: actDot('#2BB673'), text: 'NFS-e 000142 emitida — Studio Bloom Arquitetura', time: 'Hoje, 09:14' }, { iconWrap: actIcon('#E03C31'), dot: actDot('#E03C31'), text: 'Contrato enviado pelo Autentique — Construtora Horizonte', time: 'Ontem, 16:40' }, { iconWrap: actIcon('#3E8EDE'), dot: actDot('#3E8EDE'), text: 'Pagamento recebido — Mercado Vista Verde', time: 'Ontem, 11:02' }, { iconWrap: actIcon('#F2A93B'), dot: actDot('#F2A93B'), text: 'Mensalidade em atraso — Academia Pulse', time: '2 dias atrás' }, { iconWrap: actIcon('#2BB673'), dot: actDot('#2BB673'), text: 'Novo cliente cadastrado — João Pedro Salgados', time: '14/04/2026' }, ]; const upcoming = st.charges.filter(c => this.chargeStatus(c) !== 'pago').slice(0, 3).map(c => { const cl = this.client(c.clientId); return { name: cl.name, due: c.dueDate ? 'Vence ' + c.dueDate : 'Vence dia ' + (c.due != null ? c.due : cl.payDay) + '/' + MM, value: this.fmt(this.chargeRemaining(c)) }; }); const fsum = (k) => st.flow.reduce((s, m) => s + m[k], 0); const finCount = fsum('inC'), finVal = fsum('inV'), foutCount = fsum('outC'), foutVal = fsum('outV'); const fnetC = finCount - foutCount, fnetV = finVal - foutVal; const flowCardDef = [ ['Entradas de clientes', String(finCount), '+' + this.fmt(finVal) + ' / MRR', '#2BB673'], ['Saídas de clientes', String(foutCount), '−' + this.fmt(foutVal) + ' / MRR', '#E03C31'], ['Saldo líquido', (fnetC >= 0 ? '+' : '') + fnetC, (fnetV >= 0 ? '+' : '−') + this.fmt(Math.abs(fnetV)) + ' / MRR', fnetV >= 0 ? '#2BB673' : '#E03C31'], ]; const maxFlow = Math.max(1, ...st.flow.map(m => Math.max(m.inV, m.outV))); return (
{dashKpis.map((k, i) => (
{k.label}
{k.value}
{k.delta}
))}
Entradas e saídas de clientes
Movimentação · {ANO}
{flowCardDef.map(([label, qty, val, accent], i) => (
{label}
{qty}
clientes
{val}
))}
{st.flow.map((m, i) => (
{m.month}
))}
Entradas (MRR) Saídas (MRR)
Receita recorrente — últimos 6 meses
em R$ mil
{chartBars.map((b, i) => (
{b.top}
{b.month}
))}
Atividade recente
{activity.map((a, i) => (
{a.text}
{a.time}
))}
Próximos vencimentos
Cobranças que vencem nos próximos dias
{upcoming.map((u, i) => (
{u.name}
{u.due}
{u.value}
))} {upcoming.length === 0 && (
Nenhuma cobrança pendente 🎉
)}
); } /* ---------- Financeiro ---------- */ renderFin() { const st = this.state; const tab = st.finTab; const finTabStyle = (k) => `padding:9px 16px;border-radius:999px;border:none;cursor:pointer;font-size:12.5px;font-weight:600;font-family:inherit;background:${tab === k ? '#E03C31' : 'transparent'};color:${tab === k ? '#fff' : '#B7C0CB'};white-space:nowrap`; const finTabs = [['fluxo', 'Fluxo de Caixa'], ['receb', 'Recebimentos'], ['desp', 'Despesas'], ['cat', 'Categorias'], ['extrato', 'Extrato']]; return (
{finTabs.map(([k, l]) => ( ))}
{tab === 'fluxo' && this.renderFluxo()} {tab === 'receb' && this.renderReceb()} {tab === 'desp' && this.renderDesp()} {tab === 'cat' && this.renderCat()} {tab === 'extrato' && this.renderExtrato()}
); } /* livro-caixa (entradas/saídas pagas, ordenado por data, com saldo acumulado) */ ledger() { const st = this.state; const cv = this._cv; const catName = (id) => (st.categories.find(c => c.id === id) || {}).name || '—'; const parseD = (d) => { const p = String(d).split('/'); return new Date(+p[2], (+p[1] || 1) - 1, +p[0] || 1); }; let ledger = []; st.charges.forEach(c => { const p = this.chargePaid(c); if (p > 0) ledger.push({ date: c.paidDate || todayStr(), desc: this.client(c.clientId).name + (this.chargeRemaining(c) > 0 ? ' (parcial)' : ''), cat: 'Mensalidade de clientes', type: 'entrada', value: p, src: 'Asaas' }); }); st.receipts.forEach(r => { const p = this.receiptPaid(r); if (p > 0) ledger.push({ date: r.date, desc: r.desc + (this.receiptRemaining(r) > 0 ? ' (parcial)' : ''), cat: catName(r.catId), type: 'entrada', value: p, src: 'Manual' }); }); st.expenses.forEach(e => { const p = this.expensePaid(e); if (p > 0) ledger.push({ date: e.date, desc: e.desc + (this.expenseRemaining(e) > 0 ? ' (parcial)' : ''), cat: catName(e.catId), type: 'saida', value: p, src: e.supplier }); }); ledger.sort((a, b) => parseD(a.date) - parseD(b.date)); let run = st.openingBalance; return ledger.map(l => { run += l.type === 'entrada' ? l.value : -l.value; return { ...l, balance: run }; }); } renderFluxo() { const st = this.state; const cv = this._cv; const ledgerAll = this.ledger(); const ledger = ledgerAll.filter(l => this.inPeriod(l.date)); const cashInAll = ledgerAll.filter(l => l.type === 'entrada').reduce((s, l) => s + l.value, 0); const cashOutAll = ledgerAll.filter(l => l.type === 'saida').reduce((s, l) => s + l.value, 0); const cashIn = ledger.filter(l => l.type === 'entrada').reduce((s, l) => s + l.value, 0); const cashOut = ledger.filter(l => l.type === 'saida').reduce((s, l) => s + l.value, 0); const saldoAtual = st.openingBalance + cashInAll - cashOutAll; const resultado = cashIn - cashOut; const aReceberFin = st.charges.reduce((s, c) => s + this.chargeRemaining(c), 0) + st.receipts.reduce((s, r) => s + this.receiptRemaining(r), 0); const aPagarFin = st.expenses.reduce((s, e) => s + this.expenseRemaining(e), 0); const inCount = ledger.filter(l => l.type === 'entrada').length, outCount = ledger.filter(l => l.type === 'saida').length; const rotulo = this.temFiltro() ? 'período' : MES_LOWER; const fluxoKpiRows = [ { label: 'Saldo atual em caixa', value: this.fmt(saldoAtual), sub: 'Saldo inicial ' + this.fmt(st.openingBalance), border: '#E03C31', color: '#fff' }, { label: 'Entradas (' + rotulo + ')', value: this.fmt(cashIn), sub: inCount + ' lançamentos', border: '#2BB673', color: '#2BB673' }, { label: 'Saídas (' + rotulo + ')', value: this.fmt(cashOut), sub: outCount + ' lançamentos', border: '#F2A93B', color: '#EB6258' }, { label: this.temFiltro() ? 'Resultado do período' : 'Resultado do mês', value: (resultado >= 0 ? '+' : '−') + this.fmt(Math.abs(resultado)), sub: 'Entradas − saídas', border: resultado >= 0 ? '#2BB673' : '#E03C31', color: resultado >= 0 ? '#2BB673' : '#E03C31' }, ]; const secondaryFin = [ { label: 'A receber — cobranças em aberto', value: this.fmt(aReceberFin), color: '#F2A93B' }, { label: 'A pagar — despesas pendentes', value: this.fmt(aPagarFin), color: '#EB6258' }, ]; const cashDemo = [[16200, 11800], [17500, 12400], [19000, 13100], [20100, 14200], [21300, 15050]]; const cashHist = LAST6.map((m, i) => i < 5 ? [m, cashDemo[i][0], cashDemo[i][1]] : [m, cashIn, cashOut]); const maxCash = Math.max(1, ...cashHist.map(d => Math.max(d[1], d[2]))); return (
Fluxo de caixa — {this.periodLabel()}
{this.can('editar') && ( )}
{fluxoKpiRows.map((k, i) => (
{k.label}
{k.value}
{k.sub}
))}
Entradas × Saídas — 6 meses
{cashHist.map((d, i) => (
{d[0]}
))}
Entradas Saídas
{secondaryFin.map((s, i) => (
{s.label}
{s.value}
))}
); } renderReceb() { const st = this.state; const cv = this._cv; const catName = (id) => (st.categories.find(c => c.id === id) || {}).name || '—'; const active = st.clients.filter(c => c.status !== 'inativo'); const mrr = active.reduce((s, c) => s + c.monthly, 0); const visiveis = st.charges.filter(c => this.chargeStatus(c) !== 'pago' || this.inPeriod(c.paidDate)); // recebido = soma do que efetivamente entrou (inclui a parte já paga das parciais) const recebido = visiveis.reduce((s, c) => s + (this.inPeriod(c.paidDate) || !c.paidDate ? this.chargePaid(c) : 0), 0); const areceber = visiveis.filter(c => this.chargeStatus(c) !== 'pago').reduce((s, c) => s + this.chargeRemaining(c), 0); const inad = visiveis.filter(c => c.status === 'atrasado').reduce((s, c) => s + this.chargeRemaining(c), 0); const canEdit = this.can('editar'); const nPagos = visiveis.filter(c => this.chargePaid(c) > 0).length; const nParciais = visiveis.filter(c => this.chargeStatus(c) === 'parcial').length; const finKpis = [ { label: 'Receita Mensal Recorrente', value: this.fmt(mrr), sub: active.length + ' clientes ativos', border: '#E03C31' }, { label: this.temFiltro() ? 'Recebido no período' : 'Recebido em ' + MES_NOME, value: this.fmt(recebido), sub: nPagos + ' pagamento(s)' + (nParciais ? ' · ' + nParciais + ' parcial(is)' : ''), border: '#2BB673' }, { label: 'A receber', value: this.fmt(areceber), sub: visiveis.filter(c => this.chargeStatus(c) !== 'pago').length + ' cobranças abertas', border: '#F2A93B' }, { label: 'Inadimplência', value: this.fmt(inad), sub: visiveis.filter(c => c.status === 'atrasado').length + ' em atraso', border: '#E03C31' }, ]; const badge = { pago: ['Pago', '#2BB673'], parcial: ['Parcial', '#3E8EDE'], pendente: ['Pendente', '#F2A93B'], atrasado: ['Atrasado', '#E03C31'] }; const order = { atrasado: 0, parcial: 1, pendente: 2, pago: 3 }; const chargeRows = [...visiveis].sort((a, b) => (order[this.chargeStatus(a)] - order[this.chargeStatus(b)])).map(c => { const cl = this.client(c.clientId); const stt = this.chargeStatus(c); const [bl, bc] = badge[stt]; const cval = cv(c); const cdue = c.due != null ? c.due : cl.payDay; return { c, cl, bl, bc, stt, cval, pago: this.chargePaid(c), falta: this.chargeRemaining(c), cdue }; }); const recRows = st.receipts.filter(r => this.inPeriod(r.date)).map(r => ({ obj: r, desc: r.desc, cat: catName(r.catId), date: r.date, value: r.value, pago: this.receiptPaid(r), falta: this.receiptRemaining(r), valueFmt: this.fmt(r.value), isRecur: r.recur === 'mensal' })); const avatar = (color) => sx('width:34px;height:34px;border-radius:9px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;background:#28323E;color:' + color); return (
{finKpis.map((k, i) => (
{k.label}
{k.value}
{k.sub}
))}
Mensalidades — {this.periodLabel()}
Cobranças via Asaas · registre o pagamento e emita a NFS-e
{canEdit && ( )}
Pago Pendente Atrasado
Cliente
Serviço
Valor
Status
Ações
{chargeRows.map(({ c, cl, bl, bc, stt, cval, pago, falta, cdue }) => (
{this.initials(cl.name)}
{cl.name}
{c.dueDate ? 'Venc. ' + c.dueDate : 'Venc. dia ' + cdue}
{c.desc || cl.service}
{this.fmt(cval)}
{stt === 'parcial' &&
Pago {this.fmt(pago)} · falta {this.fmt(falta)}
}
{bl}
{canEdit && stt !== 'pago' && ( )} {canEdit && stt === 'pago' && !c.nfId && ( )} {!!c.nfId && ( NFS-e {c.nfId} )} {canEdit && ( )} {canEdit && ( )}
))}
Recebimentos avulsos
{canEdit && ( )}
Descrição
Categoria
Data
Valor
{canEdit &&
Ações
}
{recRows.map((r, i) => (
{r.desc}
{r.isRecur && ↻ Recorrente mensal}
{r.cat}
{r.date}
+ {this.fmt(r.pago)}
{r.falta > 0 &&
de {r.valueFmt} · falta {this.fmt(r.falta)}
}
{canEdit && (
{r.falta > 0 && }
)}
))}
); } renderDesp() { const st = this.state; const canEdit = this.can('editar'); const catName = (id) => (st.categories.find(c => c.id === id) || {}).name || '—'; const expBadge = { pago: ['Pago', '#2BB673'], parcial: ['Parcial', '#3E8EDE'], pendente: ['Pendente', '#F2A93B'] }; const despesas = st.expenses.filter(e => this.inPeriod(e.date)); const totalDespMesFmt = this.fmt(despesas.reduce((s, e) => s + e.value, 0)); return (
{this.temFiltro() ? 'Total lançado no período' : 'Total lançado no mês'}: {totalDespMesFmt}
{canEdit && ( )}
Descrição
Categoria
Fornecedor
Data
Valor
Status / Ações
{despesas.map(e => { const stt = this.expenseStatus(e); const [bl, bc] = expBadge[stt]; const falta = this.expenseRemaining(e); return (
{e.desc}
{e.recur === 'mensal' && ↻ Recorrente mensal}
{catName(e.catId)}
{e.supplier}
{e.date}
{this.fmt(e.value)}
{stt === 'parcial' &&
Pago {this.fmt(this.expensePaid(e))} · falta {this.fmt(falta)}
}
{canEdit && stt !== 'pago' && ( )} {bl} {canEdit && ( )} {canEdit && ( )}
); })}
); } renderCat() { const st = this.state; const canEdit = this.can('editar'); const catTabStyle = (on) => `flex:1;padding:9px;border-radius:8px;border:1.5px solid ${on ? '#E03C31' : 'rgba(255,255,255,.12)'};background:${on ? 'rgba(224,60,49,.1)' : '#0E1318'};color:${on ? '#fff' : '#B7C0CB'};font-weight:600;font-size:12.5px;cursor:pointer;font-family:inherit`; const catEntrada = st.categories.filter(c => c.type === 'entrada'); const catSaida = st.categories.filter(c => c.type === 'saida'); const catList = (list) => (
{list.map(c => (
{c.name} {canEdit && (
)}
))}
); return (
{canEdit && (
{st.catForm.id ? 'Editar categoria' : 'Nova categoria'}
this.setCat('name', e.target.value)} placeholder="Nome da categoria" className="kes-in" style={sx('flex:1;min-width:180px;' + IN13)} />
{st.catForm.id && }
)}
Categorias de entrada
{catList(catEntrada)}
Categorias de saída
{catList(catSaida)}
); } renderExtrato() { const ledger = this.ledger().filter(l => this.inPeriod(l.date)); const rows = [...ledger].reverse(); return (
{this.temFiltro() ? 'Extrato — ' + this.periodLabel() : 'Extrato — todas as movimentações'}
Entradas e saídas com saldo acumulado
Data
Descrição
Categoria
Tipo
Valor
Saldo
{rows.map((r, i) => (
{r.date}
{r.desc}
{r.cat}
{r.type === 'entrada' ? 'Entrada' : 'Saída'}
{(r.type === 'entrada' ? '+ ' : '− ') + this.fmt(r.value)}
{this.fmt(r.balance)}
))}
); } /* ---------- Clientes ---------- */ renderCli() { const st = this.state; const filt = st.clientFilter; const q = st.search.trim().toLowerCase(); const canEdit = this.can('editar'); const filtered = st.clients .filter(c => filt === 'todos' ? true : filt === 'ativos' ? c.status === 'ativo' : filt === 'pendentes' ? (c.status === 'pendente' || c.contract === 'aguardando') : c.status === 'inativo') .filter(c => !q || c.name.toLowerCase().includes(q) || c.doc.toLowerCase().includes(q)) .filter(c => this.inPeriod(c.entry)); const tabStyle = (k) => `padding:8px 16px;border-radius:999px;border:none;cursor:pointer;font-size:12.5px;font-weight:600;font-family:inherit;background:${filt === k ? '#E03C31' : 'transparent'};color:${filt === k ? '#fff' : '#B7C0CB'};transition:all .14s`; const filterTabs = [['todos', 'Todos'], ['ativos', 'Ativos'], ['pendentes', 'Pendentes'], ['inativos', 'Inativos / Saída']]; const sBadge = { ativo: ['Ativo', '#2BB673'], pendente: ['Inadimplente', '#E03C31'], inativo: ['Saída', '#7C8794'] }; const avatar = sx('width:34px;height:34px;border-radius:9px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;background:#28323E;color:#B7C0CB'); return (
{filterTabs.map(([k, l]) => ( ))}
{canEdit && ( )}
Cliente
Tipo
Serviço
Mensal
Entrada
Status
{canEdit &&
Ações
}
{filtered.map(c => { const [sl, sc] = sBadge[c.status]; return (
{this.initials(c.name)}
{c.name}
{c.doc}
{c.type}
{c.service}
{this.fmt(c.monthly)}
{c.entry}
{sl}
{canEdit && (
)}
); })} {filtered.length === 0 && (
Nenhum cliente encontrado.
)}
); } /* ---------- Contratos ---------- */ renderCon() { const st = this.state; const cBadge = { assinado: ['Assinado', '#2BB673'], aguardando: ['Aguardando assinatura', '#F2A93B'], encerrado: ['Encerrado', '#7C8794'], rascunho: ['Rascunho · não enviado', '#7C8794'] }; return (
Integração com Autentique conectada
Contratos são preenchidos com os dados do cliente e enviados para assinatura digital automaticamente.
Documento / Cliente
Modelo
Enviado em
Status Autentique
{st.clients.filter(c => this.inPeriod(c.sentAt)).map(c => { const [sl, sc] = cBadge[c.contract] || cBadge.rascunho; const canSend = c.contract === 'rascunho' && this.can('editar'); return (
Contrato — {c.name.split(' ').slice(0, 2).join(' ')}
{c.name}
{c.service}
{c.sentAt}
{sl}
{canSend ? ( ) : ( Ver → )}
); })}
); } /* ---------- Notas Fiscais ---------- */ renderNf() { const st = this.state; const cv = this._cv; const pendingNf = st.charges.filter(c => c.status === 'pago' && !c.nfId).map(c => { const cl = this.client(c.clientId); return { id: c.id, name: cl.name, service: (c.desc || cl.service), valueFmt: this.fmt(cv(c)) }; }); return (
Emissão de NFS-e — Prefeitura Municipal
Integração gov.br ativa. Notas de serviço são transmitidas direto ao sistema da prefeitura.
Aguardando emissão
{pendingNf.map(p => (
{p.name}
Pago · sem NF
{p.service}
{p.valueFmt}
{this.can('editar') && }
))} {pendingNf.length === 0 && (
Nenhuma nota pendente 🎉
)}
Notas emitidas
Nº NFS-e
Tomador
Emissão
Valor
PDF / Ações
{st.nfs.filter(n => this.inPeriod(n.date)).map((n, i) => (
{n.number}
{this.client(n.clientId).name}
{n.date}
{this.fmt(n.value)}
this.flash('O PDF fica disponível quando a integração real com a prefeitura estiver ativa')}> Baixar {this.can('editar') && ( )}
))}
); } /* ---------- Wizard Novo Cliente ---------- */ renderWizard() { const st = this.state; const f = st.form; const ss = st.sendState; const isPJ = f.type === 'PJ'; const dayOptions = Array.from({ length: 31 }, (_, i) => String(i + 1)); const stepDefs = [[1, 'Cadastro'], [2, 'Acordo'], [3, 'Modelo'], [4, 'Finalizar']]; const tStyle = (on) => `flex:1;padding:12px;border-radius:10px;border:1.5px solid ${on ? '#E03C31' : 'rgba(255,255,255,.12)'};background:${on ? 'rgba(224,60,49,.1)' : '#0E1318'};color:${on ? '#fff' : '#B7C0CB'};font-weight:600;font-size:13px;cursor:pointer;font-family:inherit`; const choiceStyle = (on) => `flex:1;text-align:left;padding:14px;border-radius:12px;border:1.5px solid ${on ? '#E03C31' : 'rgba(255,255,255,.12)'};background:${on ? 'rgba(224,60,49,.08)' : '#0E1318'};cursor:pointer;font-family:inherit;color:${on ? '#fff' : '#B7C0CB'}`; const tmpls = [['gestao', 'Prestação de Serviços', 'Gestão de redes sociais e conteúdo'], ['trafego', 'Tráfego Pago', 'Gestão de anúncios e mídia paga'], ['completa', 'Estratégia Completa', 'Pacote full de marketing e vendas']]; const titleMap = { gestao: 'Contrato de Prestação de Serviços de Marketing', trafego: 'Contrato de Gestão de Tráfego Pago', completa: 'Contrato de Estratégia Completa de Marketing' }; const contractTitle = titleMap[f.template] || titleMap.gestao; const dispName = f.name || (isPJ ? '[Razão Social]' : '[Nome do Cliente]'); const dispDoc = f.doc || (isPJ ? '[CNPJ]' : '[CPF]'); const monthlyNum = this.parseV(f.monthly); const dispMonthly = f.monthly ? this.fmt(monthlyNum) : '[valor mensal]'; const pv = { name: dispName, doc: dispDoc, docLabel: isPJ ? 'CNPJ' : 'CPF', service: f.service, monthly: dispMonthly, payment: f.payment, payDay: f.payDay, warranty: f.warranty, initials: this.initials(f.name || 'NC') }; const cobTotal = f.cobrancas.reduce((s, c) => s + this.parseV(c.value), 0); const successTitle = f.sendNow ? 'Contrato enviado pelo Autentique!' : 'Cliente cadastrado!'; const successMsg = f.sendNow ? ('O documento foi enviado para ' + (f.name || 'o cliente') + ' e está aguardando assinatura. O cliente e as cobranças no Asaas já foram criados.') : ((f.name || 'O cliente') + ' foi cadastrado e as cobranças criadas no Asaas. O contrato está salvo como rascunho — você pode enviá-lo pelo Autentique quando quiser, na aba Contratos.'); const MARK = 'background:#F6BCB6;padding:1px 5px;border-radius:4px;font-weight:700'; const showFooter = !(st.step === 4 && ss !== 'idle'); return (
{/* cabeçalho */}
Novo Cliente
{/* passos */}
{stepDefs.map(([n, l]) => { const done = st.step > n, cur = st.step === n; return (
{done ? '✓' : String(n)}
{l}
); })}
{/* PASSO 1: CADASTRO */} {st.step === 1 && (
Dados de identificação do cliente.
Endereço — usado no boleto (Asaas) e na nota fiscal (NFS-e)
)} {/* PASSO 2: ACORDO */} {st.step === 2 && (
Condições negociadas — estes dados serão inseridos automaticamente no contrato.
Cobranças no Asaas Conectado
Crie uma ou mais cobranças — útil quando o cliente paga valores diferentes ao longo do contrato (ex.: setup, mensalidade, produção de vídeo).
{f.cobrancas.map(cb => (
{f.cobrancas.length > 1 && ( )}
))}
Total das cobranças
{this.fmt(cobTotal)}
)} {/* PASSO 3: MODELO */} {st.step === 3 && (
Escolha o modelo de contrato. Os dados do cliente e do acordo serão mesclados automaticamente.
{tmpls.map(([k, n, d]) => { const on = f.template === k; return ( ); })}
this.flash('O envio de modelos .docx fica disponível na integração real')}>
Enviar novo modelo (.docx)
Pré-visualização — dados mesclados
{contractTitle}

Pelo presente instrumento particular, de um lado {this.state.empresa.razao || 'KeS Assessoria de Marketing'}{this.state.empresa.cnpj ? , CNPJ nº {this.state.empresa.cnpj} : null}, doravante CONTRATADA, e de outro lado {pv.name}, inscrito(a) sob o {pv.docLabel} nº {pv.doc}, doravante CONTRATANTE, têm entre si justo e acordado o seguinte:

Cláusula 1ª — Objeto. A CONTRATADA prestará serviços de {pv.service}.

Cláusula 2ª — Remuneração. O valor mensal será de {pv.monthly}, pago via {pv.payment}, com vencimento todo dia {pv.payDay}.

Cláusula 3ª — Vigência e garantia. O presente contrato vigora sob a condição de {pv.warranty}.

Cláusula 4ª — Foro. Fica eleito o foro da comarca de Caxias do Sul/RS para dirimir quaisquer questões oriundas deste contrato.

)} {/* PASSO 4: AUTENTIQUE */} {st.step === 4 && (
{ss === 'idle' && (
{f.sendNow && (
Confira o envelope que será enviado para assinatura digital pelo Autentique.
{contractTitle}
PDF gerado · {pv.name}
Signatários
{pv.initials}
{pv.name}
this.setF('signerEmail', e.target.value)} placeholder="e-mail do cliente para assinatura" className="kes-underline" style={sx('background:transparent;border:none;border-bottom:1px solid rgba(255,255,255,.15);padding:4px 0;color:#B7C0CB;font-size:12px;outline:none;width:100%;margin-top:2px')} />
Assina como CONTRATANTE
{f.extraSigners.map(s => (
{s.name ? this.initials(s.name) : '+'}
this.updateSigner(s.id, 'name', e.target.value)} placeholder="Nome do signatário" className="kes-underline" style={sx('background:transparent;border:none;border-bottom:1px solid rgba(255,255,255,.15);padding:3px 0;color:#fff;font-size:13px;font-weight:600;outline:none;width:100%')} /> this.updateSigner(s.id, 'email', e.target.value)} placeholder="e-mail para assinatura" className="kes-underline" style={sx('background:transparent;border:none;border-bottom:1px solid rgba(255,255,255,.15);padding:3px 0;color:#B7C0CB;font-size:12px;outline:none;width:100%')} />
Assina como CONTRATANTE
))}
KS
Ketherly Schmidt — {this.state.empresa.razao || 'KeS Assessoria'}
{this.state.empresa.email || 'kesassessoria@kesassessoria.com.br'}
Assina como CONTRATADA
)} {!f.sendNow && (
Contrato salvo como rascunho
O cliente será cadastrado e as cobranças criadas no Asaas. O contrato fica pronto e você poderá enviá-lo pelo Autentique a qualquer momento na aba Contratos.
)}
)} {ss === 'sending' && (
Enviando para o Autentique…
Gerando PDF e disparando solicitação de assinatura
)} {ss === 'done' && (
{successTitle}
{successMsg}
)}
)}
{/* rodapé */} {showFooter && (
{!!st.wizErro && (
{st.wizErro}
)}
)}
); } /* ---------- Modal NFS-e ---------- */ renderNfModal() { const st = this.state; const cv = this._cv; const nf = st.nf; const ch = st.charges.find(c => c.id === nf.chargeId); const cl = ch ? this.client(ch.clientId) : {}; const v = ch ? cv(ch) : 0; const emp = st.empresa; const aliq = parseFloat(String(emp.aliquota).replace(',', '.')) || 0; const docDigitos = String(cl.doc || '').replace(/\D/g, ''); const pendencias = []; if (!emp.cnpj || !emp.codTrib || !aliq) pendencias.push('Complete os dados fiscais da empresa (CNPJ, código de tributação e alíquota) em Administração → Integrações.'); if (!docDigitos) pendencias.push('Este cliente está sem CPF/CNPJ — a NFS-e exige o documento do tomador.'); if (!cl.endereco && !cl.cep) pendencias.push('Este cliente está sem endereço — recomendado para a NFS-e.'); return (
{nf.stage === 'confirm' && (
Emissão de NFS-e
Confirmar emissão
Tomador{cl.name}
Documento{cl.doc || 'não informado'}
Serviço{ch && (ch.desc || cl.service)}
Cód. tributação nacional{emp.codTrib || 'não configurado'}
Valor do serviço{this.fmt(v)}
ISS{aliq ? ' (' + String(emp.aliquota) + '%)' : ''}{aliq ? this.fmt(v * aliq / 100) : '—'}
{pendencias.length > 0 && (
{pendencias.map((pd, i) =>
⚠ {pd}
)}
)}
A nota será transmitida ao sistema da Prefeitura via integração gov.br. Esta ação não pode ser desfeita.
)} {nf.stage === 'loading' && (
Transmitindo à Prefeitura…
Aguardando autorização do município via gov.br
)} {nf.stage === 'done' && (
NFS-e autorizada!
Número da nota
{nf.number || ''}
)}
); } /* ---------- Modal Nova Cobrança (Asaas) ---------- */ renderCobModal() { const st = this.state; const cob = st.cob; const parseCob = this.parseV(cob.value); const cobCanCreate = !!(cob.clientId && parseCob > 0); const cobActiveClients = st.clients.filter(c => c.status !== 'inativo'); const dayOptions = Array.from({ length: 31 }, (_, i) => String(i + 1)); return (
{cob.stage === 'form' && (
Asaas
Conectado
{cob.id ? 'Editar cobrança' : 'Nova cobrança'}
{cob.recur === 'parcelada' && ( )}
)} {cob.stage === 'loading' && (
Criando cobrança no Asaas…
Gerando link de pagamento e enviando ao cliente
)} {cob.stage === 'done' && (
Cobrança criada!
{this.fmt(parseCob)} enviada via Asaas. O cliente recebe o link de pagamento por e-mail e WhatsApp.
)}
); } /* ---------- Modal Nova Despesa ---------- */ renderDespesaModal() { const st = this.state; const d = st.despesa; const dspParse = this.parseV(d.value); const catTabStyle = (on) => `flex:1;padding:9px;border-radius:8px;border:1.5px solid ${on ? '#E03C31' : 'rgba(255,255,255,.12)'};background:${on ? 'rgba(224,60,49,.1)' : '#0E1318'};color:${on ? '#fff' : '#B7C0CB'};font-weight:600;font-size:12.5px;cursor:pointer;font-family:inherit`; return (
Saída
{d.id ? 'Editar despesa' : 'Nova despesa'}
Situação
); } /* ---------- Modal Registrar Recebimento ---------- */ renderRecebModal() { const st = this.state; const r = st.receb; const rcbParse = this.parseV(r.value); return (
Entrada
{r.id ? 'Editar recebimento' : 'Registrar recebimento'}
{r.parcial && ( )}
); } /* ---------- Modal Saldo Inicial ---------- */ renderSaldoModal() { const st = this.state; return (
Caixa
Ajustar saldo inicial
Informe quanto há em caixa hoje. Esse valor é a base do fluxo — as entradas e saídas são somadas a partir dele.
); } /* ---------- Tela "esqueci minha senha" ---------- */ renderRecuperar() { const r = this.state.recuperar; return (
KeS
Redefinir senha
Para provar que é você, informe o código de recuperação. Ele fica no arquivo config.php, no servidor: hPanel → Gerenciador de Arquivos → pasta do site.
{!!r.erro &&
{r.erro}
}
); } /* ---------- Tela de login / primeiro acesso ---------- */ renderAuth() { const f = this.state.authForm; // no local: não há usuário salvo · na versão publicada: quem diz é o servidor const primeiroAcesso = this._modo === 'php' ? this.state.phpPrimeiroAcesso : this.state.users.length === 0; const enviar = () => primeiroAcesso ? this.criarAdmin() : this.entrar(); if (this.state.recuperar) return this.renderRecuperar(); return (
KeS
KeS Assessoria
Controle de Clientes
{primeiroAcesso && (
Primeiro acesso. Crie a conta da administradora — ela terá acesso total e poderá cadastrar outros usuários com permissões limitadas.
)}
{primeiroAcesso && ( )} {primeiroAcesso && ( )} {!!f.erro &&
{f.erro}
} {!primeiroAcesso && this._modo === 'php' && ( )}
); } /* ---------- Usuários e Permissões ---------- */ renderUsers() { const st = this.state; const me = this.currentUser(); const resumoPerms = (u) => { if (u.perms.admin) return 'Acesso total (administradora)'; const telas = PERM_TELAS.filter(([k]) => u.perms[k]).map(([, l]) => l).join(', ') || 'Nenhuma tela'; const extras = [u.perms.editar ? 'edita' : 'somente leitura', u.perms.exportar ? 'exporta' : null].filter(Boolean).join(' · '); return telas + ' — ' + extras; }; return (
Cada pessoa tem seu próprio login. As permissões controlam quais telas aparecem e o que pode ser feito.
Nome
Usuário
Permissões
Ações
{st.users.map(u => (
{this.initials(u.nome)}
{u.nome}{me && me.id === u.id ? ' (você)' : ''}
{u.cargo || (u.perms.admin ? 'Administradora' : 'Usuário')}
{u.user}
{resumoPerms(u)}
{!(me && me.id === u.id) && ( )}
))}
Dica: para trocar a sua senha, clique em Editar no seu usuário e digite a nova senha.
); } /* ---------- Modal de usuário ---------- */ renderUserModal() { const m = this.state.userModal; const chk = (k, label) => ( ); return (
Acesso
{m.id == null ? 'Novo usuário' : 'Editar usuário'}
Telas que pode ver
{PERM_TELAS.map(([k, l]) => chk(k, l))}
O que pode fazer
{PERM_ACOES.map(([k, l]) => chk(k, l))}
{!!m.erro &&
{m.erro}
}
); } /* ---------- Modal de pagamento (total / parcial) ---------- */ renderPagamentoModal() { const pg = this.state.pagamento; const ch = this.state.charges.find(c => c.id === pg.chargeId) || {}; const cl = this.client(ch.clientId); const total = this.cval(ch); const jaPago = this.chargePaid(ch); const falta = this.chargeRemaining(ch); const parcial = pg.mode === 'parcial'; const add = parcial ? this.parseV(pg.valor) : falta; const okValor = !parcial || (add > 0 && add <= falta); const restante = Math.max(0, falta - add); const modoBtn = (on) => `flex:1;text-align:left;padding:12px 14px;border-radius:11px;border:1.5px solid ${on ? '#E03C31' : 'rgba(255,255,255,.12)'};background:${on ? 'rgba(224,60,49,.08)' : '#0E1318'};cursor:pointer;font-family:inherit;color:${on ? '#fff' : '#B7C0CB'}`; return (
{ if (e.target === e.currentTarget) this.closePagamento(); }}>
Entrada
Registrar pagamento
{cl.name} · {ch.desc || cl.service}
Valor da cobrança{this.fmt(total)}
{jaPago > 0 &&
Já recebido{this.fmt(jaPago)}
}
Falta receber{this.fmt(falta)}
{parcial && ( )}
); } /* ---------- Modal de pagamento de despesa (total / parcial) ---------- */ renderPagarDespModal() { const pg = this.state.pagarDesp; const e = this.state.expenses.find(x => x.id === pg.expenseId) || {}; const total = e.value || 0; const jaPago = this.expensePaid(e); const falta = this.expenseRemaining(e); const parcial = pg.mode === 'parcial'; const add = parcial ? this.parseV(pg.valor) : falta; const okValor = !parcial || (add > 0 && add <= falta); const restante = Math.max(0, falta - add); const modoBtn = (on) => `flex:1;text-align:left;padding:12px 14px;border-radius:11px;border:1.5px solid ${on ? '#E03C31' : 'rgba(255,255,255,.12)'};background:${on ? 'rgba(224,60,49,.08)' : '#0E1318'};cursor:pointer;font-family:inherit;color:${on ? '#fff' : '#B7C0CB'}`; return (
{ if (ev.target === ev.currentTarget) this.closePagarDesp(); }}>
Saída
Pagar despesa
{e.desc}
Valor da despesa{this.fmt(total)}
{jaPago > 0 &&
Já pago{this.fmt(jaPago)}
}
Falta pagar{this.fmt(falta)}
{parcial && ( )}
); } /* ---------- Modal de edição de cliente ---------- */ renderClienteModal() { const m = this.state.clienteModal; const isPJ = m.type === 'PJ'; const docOk = m.doc.replace(/\D/g, '') && (isPJ ? validaCNPJ(m.doc) : validaCPF(m.doc)); const tStyle = (on) => `flex:1;padding:10px;border-radius:9px;border:1.5px solid ${on ? '#E03C31' : 'rgba(255,255,255,.12)'};background:${on ? 'rgba(224,60,49,.1)' : '#0E1318'};color:${on ? '#fff' : '#B7C0CB'};font-weight:600;font-size:12.5px;cursor:pointer;font-family:inherit`; return (
Cliente
Editar cliente
Endereço
{!!m.erro &&
{m.erro}
}
); } /* ---------- Modal de exportação ---------- */ renderExportModal() { const opcoes = [ ['clientes', 'Clientes', 'Lista completa com serviço, mensalidade e status'], ['cobrancas', 'Cobranças / Mensalidades', 'Status de pagamento e NFS-e vinculada'], ['despesas', 'Despesas', 'Com categoria, fornecedor e situação'], ['recebimentos', 'Recebimentos avulsos', 'Entradas registradas manualmente'], ['extrato', 'Extrato (fluxo de caixa)', 'Todas as movimentações com saldo acumulado'], ['notas', 'Notas fiscais', 'NFS-e emitidas'], ]; return (
{ if (e.target === e.currentTarget) this.setState({ exportOpen: false }); }}>
Exportação
Exportar dados
Arquivos .csv abrem direto no Excel. {this.temFiltro() ? 'O filtro de datas atual (' + this.periodLabel() + ') será aplicado.' : 'Sem filtro de datas — exporta tudo.'}
{opcoes.map(([k, titulo, desc]) => ( ))}
); } /* ---------- Integrações ---------- */ renderIntegracoes() { const api = this.state.apiStatus; const fi = this.state.intForm; const tt = this.state.intTest; const statusChip = (ok, txtOk, txtNo) => ( {ok ? txtOk : txtNo} ); const cardT = 'font-size:15px;font-weight:800;letter-spacing:-.01em'; const p = 'font-size:12.5px;color:#B7C0CB;line-height:1.6;margin-top:8px'; const li = 'font-size:12.5px;color:#B7C0CB;line-height:1.7;margin-left:16px'; const btnS = 'background:#E03C31;color:#fff;border:none;border-radius:9px;padding:10px 16px;font-size:12.5px;font-weight:700;cursor:pointer;white-space:nowrap'; const btnT = 'background:#28323E;color:#fff;border:1px solid rgba(255,255,255,.1);border-radius:9px;padding:10px 16px;font-size:12.5px;font-weight:600;cursor:pointer;white-space:nowrap'; const ambBtn = (on) => `padding:8px 14px;border-radius:8px;border:1.5px solid ${on ? '#E03C31' : 'rgba(255,255,255,.12)'};background:${on ? 'rgba(224,60,49,.1)' : '#0E1318'};color:${on ? '#fff' : '#B7C0CB'};font-weight:600;font-size:12px;cursor:pointer;font-family:inherit`; const resultado = (qual) => tt[qual] && (
{tt[qual].mensagem}
); return (
{/* status do servidor de integrações */}
Servidor de integrações {api ? '— online ✓' : api === null ? '— offline' : '— verificando…'}
{api ? As chaves ficam guardadas com segurança neste computador, nunca no navegador. Banco de dados (SQLite): {this.state.bdStatus === 'ok' ? ativo — {api.banco} (ambiente de {api.ambiente === 'teste' ? 'TESTE' : 'produção'}), gravado a cada alteração com backup diário. Projeto próprio — sem vínculo com o KeS Metas nem com o Gerenciador. : 'sem conexão — dados só no navegador.'} : api === null ? 'Abra o sistema pelo atalho "Abrir Controle de Clientes.bat" (ele inicia o servidor com as integrações e o banco de dados). Sem ele, os dados ficam só neste navegador.' : 'Aguarde…'}
As ações do sistema seguem em modo simulado até cada conexão ser ativada. Salve as credenciais abaixo e use Testar conexão — com tudo verde, eu ativo o envio real.
{/* Dados da empresa (prestador) */}
Dados da empresa (prestador)
{statusChip(!!(this.state.empresa.cnpj && this.state.empresa.codTrib && this.state.empresa.aliquota), 'Completo', 'Incompleto')}
São os dados que a NFS-e Nacional exige de quem emite a nota (e que também preenchem o contrato). Ficam salvos automaticamente. Dica: copie o código de tributação e a alíquota de uma nota que você já emitiu no Emissor Nacional.
{/* Autentique */}
Autentique — assinatura digital de contratos
{statusChip(!!(api && api.autentique.configurado), 'Token salvo no servidor', 'Simulado')}
A API oficial (GraphQL) funciona com um token de API gerado no painel do Autentique. O plano gratuito inclui 20 documentos/mês com API liberada; o plano pago (≈ US$ 19/mês) libera documentos ilimitados.
O que você precisa fazer:
  1. Criar conta em autentique.com.br (a gratuita já serve).
  2. No painel: Perfil → API keys (painel.autentique.com.br/perfil/api) → gerar o token e colar abaixo.
  3. Colar o token abaixo, salvar e clicar em Testar conexão.
{resultado('autentique')}
{/* NFS-e */}
NFS-e — emissão de nota fiscal de serviço
{statusChip(!!(api && api.nfse.configurado), 'Certificado configurado', 'Simulado')}
A empresa já emite pelo Emissor Nacional (nfse.gov.br) com certificado digital — então o caminho é a API oficial do Sistema Nacional NFS-e (SEFIN Nacional), sem custo por nota e sem intermediário pago. O sistema gera a DPS (declaração da nota), assina com o seu certificado e transmite ao gov.br; a NFS-e volta autorizada com número e chave de acesso.
O que é preciso:
  1. Certificado e-CNPJ tipo A1 (arquivo .pfx/.p12 + senha) — é ele que assina a nota e autentica a conexão. Se o seu certificado for A3 (cartão/token físico), me avise: aí avaliamos gerar um A1 (≈ R$ 150–250/ano) ou outra saída, porque o A3 não funciona bem para automação.
  2. Dados fiscais do serviço (código de tributação nacional do serviço prestado, alíquota de ISS e regime — os mesmos que você já usa ao emitir manualmente; dá para copiar de uma nota anterior).
  3. Informar abaixo o caminho do arquivo .pfx no computador e a senha, salvar e clicar em Testar conexão — o teste confere o certificado e a conexão segura com a SEFIN Nacional, começando pelo ambiente de homologação (notas de teste, sem valor fiscal).
{resultado('nfse')}
Com o teste verde, a próxima etapa é a emissão de verdade: geramos uma nota de teste em homologação e, funcionando, ligamos a produção. Até lá o botão "Gerar NFS-e" segue simulado.
{/* Asaas */}
Asaas — cobranças e links de pagamento
{statusChip(!!(api && api.asaas.configurado), 'Chave salva no servidor (' + (api ? api.asaas.ambiente : '') + ')', 'Simulado')}
Sem mensalidade — paga-se por cobrança recebida: PIX e boleto R$ 1,99 (R$ 0,99 nos 3 primeiros meses); cartão à vista R$ 0,49 + 2,99%. PIX cai na hora; cartão em até 2 dias úteis. Criada a cobrança, o próprio Asaas envia o link/boleto ao cliente por e-mail (pacote R$ 0,99 por cobrança paga) e, se quiser, WhatsApp (R$ 0,55/mensagem).
O que você precisa fazer:
  1. Criar a conta em asaas.com (grátis; MEI usa o CCMEI; aprovação em até 2 dias úteis).
  2. No painel: menu do usuário → Integrações → Chave de API → gerar a chave (ela aparece uma única vez — copie na hora) e colar abaixo.
  3. Opcional, recomendado: criar também uma conta de testes em sandbox.asaas.com (aprovação automática, sem dinheiro real) — usamos ela primeiro para testar tudo com segurança.
  4. Colar a chave abaixo (escolhendo o ambiente certo), salvar e clicar em Testar conexão. A chave dá acesso total à conta, por isso fica guardada no servidor local, nunca no navegador.
{resultado('asaas')}
); } } ReactDOM.createRoot(document.getElementById('root')).render();