/* =========================================================================
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()}
{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.setState({ period: { from: '', to: '' } })} title="Limpar filtro de datas" className="hv-icon" style={sx('background:transparent;border:none;color:#7C8794;cursor:pointer;display:flex;padding:2px')}>
)}
{this.can('exportar') && (
this.setState({ exportOpen: true })} className="hv-ghost" style={sx('display:flex;align-items:center;gap:7px;background:#28323E;color:#fff;border:1px solid rgba(255,255,255,.1);border-radius:999px;padding:8px 15px;font-weight:600;font-size:12.5px;cursor:pointer')}>
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}
this.setState({ confirmModal: null })} style={sx(BTN_CANCEL)}>Cancelar
{ const fn = st.confirmModal.onYes; this.setState({ confirmModal: null }, () => fn && fn()); }} className="hv-red" style={sx('flex:1.4;background:#E03C31;color:#fff;border:none;border-radius:10px;padding:11px;font-size:13px;font-weight:700;cursor:pointer')}>Excluir
)}
{!!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) => (
))}
Entradas e saídas de clientes
Movimentação · {ANO}
{flowCardDef.map(([label, qty, val, accent], i) => (
))}
{st.flow.map((m, i) => (
))}
Entradas (MRR)
Saídas (MRR)
Receita recorrente — últimos 6 meses
em R$ mil
{chartBars.map((b, i) => (
))}
Atividade recente
{activity.map((a, i) => (
))}
Próximos vencimentos
Cobranças que vencem nos próximos dias
{upcoming.map((u, i) => (
))}
{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]) => (
this.setFinTab(k)} style={sx(finTabStyle(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') && (
this.openSaldo()} className="hv-ghost" style={sx('display:flex;align-items:center;gap:7px;background:#28323E;color:#fff;border:1px solid rgba(255,255,255,.1);border-radius:999px;padding:9px 16px;font-weight:600;font-size:12.5px;cursor:pointer')}>
Ajustar saldo inicial
)}
{fluxoKpiRows.map((k, i) => (
{k.label}
{k.value}
{k.sub}
))}
Entradas × Saídas — 6 meses
{cashHist.map((d, i) => (
))}
Entradas
Saídas
{secondaryFin.map((s, i) => (
))}
);
}
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 && (
this.openCob()} className="hv-red" style={sx('display:flex;align-items:center;gap:7px;background:#E03C31;color:#fff;border:none;border-radius:9px;padding:9px 14px;font-size:12.5px;font-weight:700;cursor:pointer;box-shadow:0 8px 22px rgba(224,60,49,.28)')}>
Nova cobrança
)}
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' && (
this.openPagamento(c)} className="hv-ghost" style={sx('background:#28323E;color:#fff;border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:7px 12px;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap')}>{stt === 'parcial' ? 'Receber restante' : 'Registrar pagamento'}
)}
{canEdit && stt === 'pago' && !c.nfId && (
this.openNf(c.id)} className="hv-red" style={sx('background:#E03C31;color:#fff;border:none;border-radius:8px;padding:7px 12px;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap')}>Gerar NFS-e
)}
{!!c.nfId && (
NFS-e {c.nfId}
)}
{canEdit && (
this.openCob(c)} title="Editar cobrança" className="hv-icon" style={sx('background:transparent;border:1px solid rgba(255,255,255,.12);color:#7C8794;cursor:pointer;display:flex;padding:7px;border-radius:8px')}>
)}
{canEdit && (
this.confirmarExclusao('Excluir esta cobrança de ' + cl.name + '?', () => this.deleteCharge(c.id))} title="Excluir cobrança" className="hv-danger" style={sx('background:transparent;border:1px solid rgba(255,255,255,.12);color:#7C8794;cursor:pointer;display:flex;padding:7px;border-radius:8px')}>
)}
))}
Recebimentos avulsos
{canEdit && (
this.openReceb()} className="hv-ghost" style={sx('display:flex;align-items:center;gap:7px;background:#28323E;color:#fff;border:1px solid rgba(255,255,255,.1);border-radius:999px;padding:9px 16px;font-weight:600;font-size:12.5px;cursor:pointer')}>
Registrar recebimento
)}
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 && this.completarReceb(r.obj.id)} title="Receber o restante" className="hv-ghost" style={sx('background:#28323E;color:#fff;border:1px solid rgba(255,255,255,.1);border-radius:7px;padding:6px 9px;font-size:11px;font-weight:600;cursor:pointer;white-space:nowrap')}>Receber resto }
this.openReceb(r.obj)} title="Editar" className="hv-icon" style={sx('background:transparent;border:1px solid rgba(255,255,255,.12);color:#7C8794;cursor:pointer;display:flex;padding:6px;border-radius:7px')}>
this.confirmarExclusao('Excluir o recebimento "' + r.desc + '"?', () => this.deleteReceipt(r.obj.id))} title="Excluir" className="hv-danger" style={sx('background:transparent;border:1px solid rgba(255,255,255,.12);color:#7C8794;cursor:pointer;display:flex;padding:6px;border-radius:7px')}>
)}
))}
);
}
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 && (
this.openDespesa()} className="hv-red" style={sx('display:flex;align-items:center;gap:7px;background:#E03C31;color:#fff;border:none;border-radius:999px;padding:10px 18px;font-weight:700;font-size:13px;cursor:pointer;box-shadow:0 8px 22px rgba(224,60,49,.28)')}>
Nova despesa
)}
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' && (
this.openPagarDespesa(e)} className="hv-ghost" style={sx('background:#28323E;color:#fff;border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:6px 10px;font-size:11px;font-weight:600;cursor:pointer;white-space:nowrap')}>{stt === 'parcial' ? 'Pagar resto' : 'Marcar pago'}
)}
{bl}
{canEdit && (
this.openDespesa(e)} title="Editar" className="hv-icon" style={sx('background:transparent;border:1px solid rgba(255,255,255,.12);color:#7C8794;cursor:pointer;display:flex;padding:6px;border-radius:7px')}>
)}
{canEdit && (
this.confirmarExclusao('Excluir a despesa "' + e.desc + '"?', () => this.deleteExpense(e.id))} title="Excluir" className="hv-danger" style={sx('background:transparent;border:1px solid rgba(255,255,255,.12);color:#7C8794;cursor:pointer;display:flex;padding:6px;border-radius:7px')}>
)}
);
})}
);
}
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 && (
this.editCategory(c)} title="Renomear" className="hv-icon" style={sx('background:transparent;border:none;color:#7C8794;cursor:pointer;display:flex')}>
this.confirmarExclusao('Excluir a categoria "' + c.name + '"?', () => this.removeCategory(c.id))} title="Excluir" className="hv-icon" style={sx('background:transparent;border:none;color:#7C8794;cursor:pointer;display:flex')}>
)}
))}
);
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)} />
this.setCat('type', 'entrada')} style={sx(catTabStyle(st.catForm.type === 'entrada'))}>Entrada
this.setCat('type', 'saida')} style={sx(catTabStyle(st.catForm.type === 'saida'))}>Saída
this.addCategory()} className="hv-red" style={sx('background:#E03C31;color:#fff;border:none;border-radius:10px;padding:11px 20px;font-weight:700;font-size:13px;cursor:pointer')}>{st.catForm.id ? 'Salvar' : 'Adicionar'}
{st.catForm.id &&
this.setState({ catForm: { name: '', type: st.catForm.type } })} style={sx(BTN_CANCEL + ';flex:0;padding:11px 16px')}>Cancelar }
)}
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]) => (
this.setState({ clientFilter: k })} style={sx(tabStyle(k))}>{l}
))}
{canEdit && (
this.startNew()} className="hv-red" style={sx('display:flex;align-items:center;gap:8px;background:#E03C31;color:#fff;border:none;border-radius:999px;padding:10px 18px;font-weight:700;font-size:13px;cursor:pointer;box-shadow:0 10px 30px rgba(224,60,49,.3)')}>
Novo Cliente
)}
Cliente
Tipo
Serviço
Mensal
Entrada
Status
{canEdit &&
Ações
}
{filtered.map(c => {
const [sl, sc] = sBadge[c.status];
return (
{c.type}
{c.service}
{this.fmt(c.monthly)}
{c.entry}
{sl}
{canEdit && (
this.openCliente(c)} title="Editar cliente" className="hv-icon" style={sx('background:transparent;border:1px solid rgba(255,255,255,.12);color:#7C8794;cursor:pointer;display:flex;padding:6px;border-radius:7px')}>
this.confirmarExclusao('Excluir ' + c.name + ' e todas as suas cobranças? Esta ação não pode ser desfeita.', () => this.deleteCliente(c.id))} title="Excluir cliente" className="hv-danger" style={sx('background:transparent;border:1px solid rgba(255,255,255,.12);color:#7C8794;cursor:pointer;display:flex;padding:6px;border-radius:7px')}>
)}
);
})}
{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 ? (
this.sendLater(c.id)} className="hv-red" style={sx('background:#E03C31;color:#fff;border:none;border-radius:8px;padding:7px 13px;font-size:11.5px;font-weight:700;cursor:pointer;white-space:nowrap')}>Enviar agora
) : (
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.service}
{p.valueFmt}
{this.can('editar') &&
this.openNf(p.id)} className="hv-red" style={sx('background:#E03C31;color:#fff;border:none;border-radius:9px;padding:9px 15px;font-size:12.5px;font-weight:700;cursor:pointer')}>Gerar NFS-e }
))}
{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') && (
this.confirmarExclusao('Excluir a nota ' + n.number + ' do controle? (Cancelar uma NFS-e oficial deve ser feito também no portal da prefeitura.)', () => this.deleteNf(n.number))} title="Excluir do controle" className="hv-danger" style={sx('background:transparent;border:1px solid rgba(255,255,255,.12);color:#7C8794;cursor:pointer;display:flex;padding:6px;border-radius:7px')}>
)}
))}
);
}
/* ---------- 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
this.closeWizard()} className="hv-close" style={sx('width:34px;height:34px;border-radius:9px;background:#0E1318;border:1px solid rgba(255,255,255,.1);color:#B7C0CB;cursor:pointer;display:flex;align-items:center;justify-content:center')}>
{/* 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 && (
)}
{/* PASSO 2: ACORDO */}
{st.step === 2 && (
Condições negociadas — estes dados serão inseridos automaticamente no contrato.
Serviço contratado
this.setF('service', e.target.value)} className="kes-in" style={sx(IN)}>
Gestão de Redes Sociais
Tráfego Pago
Gestão + Tráfego Pago
Consultoria de Marketing
Prospecção BDR/SDR
Estratégia Completa
Valor mensal (R$)
this.setF('monthly', e.target.value)} placeholder="2.800,00" className="kes-in" style={sx(IN)} />
Forma de pagamento
this.setF('payment', e.target.value)} className="kes-in" style={sx(IN)}>
PIX
Boleto bancário
Cartão de crédito
Cliente escolhe no link (Asaas)
Dia de vencimento
this.setF('payDay', e.target.value)} className="kes-in" style={sx(IN)}>
{dayOptions.map(d => {d} )}
Garantia / Fidelidade
this.setF('warranty', e.target.value)} className="kes-in" style={sx(IN)}>
Sem fidelidade
Fidelidade de 6 meses
Fidelidade de 12 meses
Garantia de resultado 90 dias
Cobranças no Asaas
Conectado
this.addCobranca()} className="hv-ghost" style={sx('display:flex;align-items:center;gap:6px;background:#28323E;color:#fff;border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:7px 12px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit')}>
Adicionar cobrança
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 => (
Descrição
this.updateCobranca(cb.id, 'desc', e.target.value)} placeholder="Ex.: Mensalidade" className="kes-in" style={sx('background:#131B23;border:1px solid rgba(255,255,255,.12);border-radius:8px;padding:9px 11px;color:#fff;font-size:12.5px;outline:none')} />
Valor (R$)
this.updateCobranca(cb.id, 'value', e.target.value)} placeholder="0,00" className="kes-in" style={sx('background:#131B23;border:1px solid rgba(255,255,255,.12);border-radius:8px;padding:9px 11px;color:#fff;font-size:12.5px;outline:none')} />
Recorrência
this.updateCobranca(cb.id, 'recur', e.target.value)} className="kes-in" style={sx('background:#131B23;border:1px solid rgba(255,255,255,.12);border-radius:8px;padding:9px 11px;color:#fff;font-size:12.5px;outline:none')}>
Mensal recorrente Parcela única Parcelada 3x Parcelada 6x Parcelada 12x
1º vencimento
this.updateCobranca(cb.id, 'due', e.target.value)} className="kes-in" style={sx('background:#131B23;border:1px solid rgba(255,255,255,.12);border-radius:8px;padding:8px 10px;color:#fff;font-size:12.5px;outline:none;color-scheme:dark;font-family:inherit')} />
{f.cobrancas.length > 1 && (
this.removeCobranca(cb.id)} className="hv-danger" style={sx('width:38px;height:38px;border-radius:8px;background:transparent;border:1px solid rgba(255,255,255,.12);color:#7C8794;cursor:pointer;display:flex;align-items:center;justify-content:center')}>
)}
))}
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.setF('template', k)} style={sx(`display:flex;flex-direction:column;align-items:flex-start;border:1.5px solid ${on ? '#E03C31' : 'rgba(255,255,255,.1)'};background:${on ? 'rgba(224,60,49,.08)' : '#0E1318'};border-radius:12px;padding:16px;cursor:pointer;color:${on ? '#EB6258' : '#B7C0CB'};font-family:inherit;text-align:left`)}>
{n}
{d}
);
})}
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' && (
this.setSendNow(true)} style={sx(choiceStyle(f.sendNow))}>
Enviar contrato agora
Gera o PDF e envia pelo Autentique para assinatura
this.setSendNow(false)} style={sx(choiceStyle(!f.sendNow))}>
Cadastrar sem enviar
Salva o contrato como rascunho — envie depois
{f.sendNow && (
Confira o envelope que será enviado para assinatura digital pelo Autentique.
{contractTitle}
PDF gerado · {pv.name}
Signatários
{pv.initials}
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
this.removeSigner(s.id)} className="hv-danger" style={sx('width:28px;height:28px;border-radius:7px;background:transparent;border:1px solid rgba(255,255,255,.12);color:#7C8794;cursor:pointer;display:flex;align-items:center;justify-content:center;flex-shrink:0')}>
))}
KS
Ketherly Schmidt — {this.state.empresa.razao || 'KeS Assessoria'}
{this.state.empresa.email || 'kesassessoria@kesassessoria.com.br'}
Assina como CONTRATADA
this.addSigner()} className="hv-ghost" style={sx('display:flex;align-items:center;gap:7px;background:transparent;color:#B7C0CB;border:1px dashed rgba(255,255,255,.2);border-radius:9px;padding:9px 14px;font-size:12.5px;font-weight:600;cursor:pointer;font-family:inherit;margin-top:14px;width:100%;justify-content:center')}>
Adicionar signatário
this.setF('whats', e.target.checked)} style={sx('width:16px;height:16px;accent-color:#E03C31')} />
Notificar o cliente também por WhatsApp
)}
{!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}
this.goConClose()} style={sx('background:#28323E;color:#fff;border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:11px 18px;font-size:13px;font-weight:600;cursor:pointer')}>Ver contratos
this.closeWizard()} className="hv-red" style={sx('background:#E03C31;color:#fff;border:none;border-radius:10px;padding:11px 18px;font-size:13px;font-weight:700;cursor:pointer')}>Concluir
)}
)}
{/* rodapé */}
{showFooter && (
{!!st.wizErro && (
{st.wizErro}
)}
this.prevStep()} style={sx(`background:transparent;color:${st.step === 1 ? '#3a4450' : '#B7C0CB'};border:1px solid rgba(255,255,255,.1);border-radius:10px;padding:11px 18px;font-size:13.5px;font-weight:600;cursor:${st.step === 1 ? 'default' : 'pointer'};font-family:inherit;pointer-events:${st.step === 1 ? 'none' : 'auto'}`)}>← Voltar
this.nextStep()} className="hv-red" style={sx('background:#E03C31;color:#fff;border:none;border-radius:10px;padding:11px 22px;font-size:13.5px;font-weight:700;cursor:pointer;box-shadow:0 8px 24px rgba(224,60,49,.3)')}>
{st.step === 3 ? 'Avançar para finalizar' : st.step === 4 ? (f.sendNow ? 'Enviar pelo Autentique' : 'Concluir cadastro') : 'Continuar'}
)}
);
}
/* ---------- 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.
this.closeNf()} style={sx(BTN_CANCEL)}>Cancelar
this.confirmNf()} className="hv-red" style={sx('flex:1.4;background:#E03C31;color:#fff;border:none;border-radius:10px;padding:11px;font-size:13px;font-weight:700;cursor:pointer')}>Emitir nota fiscal
)}
{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 || ''}
this.closeNf()} className="hv-red" style={sx('background:#E03C31;color:#fff;border:none;border-radius:10px;padding:11px 26px;font-size:13px;font-weight:700;cursor:pointer;margin-top:4px')}>Concluir
)}
);
}
/* ---------- 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' && (
{cob.id ? 'Editar cobrança' : 'Nova cobrança'}
this.closeCob()} style={sx(BTN_CANCEL)}>Cancelar
this.createCob()} style={sx(`flex:1.4;background:${cobCanCreate ? '#E03C31' : '#3a2a28'};color:${cobCanCreate ? '#fff' : '#7C8794'};border:none;border-radius:10px;padding:11px;font-size:13px;font-weight:700;cursor:${cobCanCreate ? 'pointer' : 'not-allowed'}`)}>{cob.id ? 'Salvar alterações' : 'Criar e enviar no Asaas'}
)}
{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.
this.closeCob()} className="hv-red" style={sx('background:#E03C31;color:#fff;border:none;border-radius:10px;padding:11px 26px;font-size:13px;font-weight:700;cursor:pointer;margin-top:4px')}>Concluir
)}
);
}
/* ---------- 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'}
this.closeDespesa()} style={sx(BTN_CANCEL)}>Cancelar
this.saveDespesa()} style={sx(`flex:1.4;background:${dspParse > 0 ? '#E03C31' : '#3a2a28'};color:${dspParse > 0 ? '#fff' : '#7C8794'};border:none;border-radius:10px;padding:11px;font-size:13px;font-weight:700;cursor:${dspParse > 0 ? 'pointer' : 'not-allowed'}`)}>{d.id ? 'Salvar alterações' : 'Salvar despesa'}
);
}
/* ---------- 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'}
Descrição
this.setReceb('desc', e.target.value)} placeholder="Ex.: Projeto avulso — site" className="kes-in" style={sx(IN13)} />
Categoria
this.setReceb('catId', e.target.value)} className="kes-in" style={sx(IN13)}>
{st.categories.filter(c => c.type === 'entrada').map(o => {o.name} )}
Valor (R$)
this.setReceb('value', e.target.value)} placeholder="0,00" className="kes-in" style={sx(IN13)} />
Data
this.setReceb('date', e.target.value)} placeholder="dd/mm/aaaa" className="kes-in" style={sx(IN13)} />
Recorrência
this.setReceb('recur', e.target.value)} className="kes-in" style={sx(IN13)}>
Único
Mensal (recorrente)
this.setReceb('parcial', e.target.checked)} style={sx('width:16px;height:16px;accent-color:#E03C31')} />
Recebido parcialmente (o restante fica pendente)
{r.parcial && (
Valor recebido agora (R$)
this.setReceb('recebido', e.target.value)} placeholder="0,00" className="kes-in" style={sx(IN13)} />
{rcbParse > 0 && this.parseV(r.recebido) > 0 ? 'Ficará pendente: ' + this.fmt(Math.max(0, rcbParse - this.parseV(r.recebido))) : 'Informe quanto entrou; o resto fica a receber'}
)}
this.closeReceb()} style={sx(BTN_CANCEL)}>Cancelar
this.saveReceb()} style={sx(`flex:1.4;background:${rcbParse > 0 ? '#E03C31' : '#3a2a28'};color:${rcbParse > 0 ? '#fff' : '#7C8794'};border:none;border-radius:10px;padding:11px;font-size:13px;font-weight:700;cursor:${rcbParse > 0 ? 'pointer' : 'not-allowed'}`)}>{r.id ? 'Salvar alterações' : (r.parcial ? 'Registrar parcial' : 'Registrar entrada')}
);
}
/* ---------- 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.
Saldo em caixa (R$)
this.setSaldo(e.target.value)} placeholder="0,00" className="kes-in" style={sx('background:#0E1318;border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:12px 14px;color:#fff;font-size:16px;font-weight:700;outline:none')} />
this.closeSaldo()} style={sx(BTN_CANCEL)}>Cancelar
this.saveSaldo()} className="hv-red" style={sx('flex:1.4;background:#E03C31;color:#fff;border:none;border-radius:10px;padding:11px;font-size:13px;font-weight:700;cursor:pointer')}>Salvar saldo
);
}
/* ---------- Tela "esqueci minha senha" ---------- */
renderRecuperar() {
const r = this.state.recuperar;
return (
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.
);
}
/* ---------- 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 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.
)}
);
}
/* ---------- 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.
this.abrirUsuario(null)} className="hv-red" style={sx('display:flex;align-items:center;gap:8px;background:#E03C31;color:#fff;border:none;border-radius:999px;padding:10px 18px;font-weight:700;font-size:13px;cursor:pointer;box-shadow:0 10px 30px rgba(224,60,49,.3)')}>
Novo usuário
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)}
this.abrirUsuario(u)} className="hv-ghost" style={sx('background:#28323E;color:#fff;border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:7px 12px;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap')}>Editar
{!(me && me.id === u.id) && (
this.removerUsuario(u.id)} className="hv-danger" style={sx('width:32px;height:32px;border-radius:8px;background:transparent;border:1px solid rgba(255,255,255,.12);color:#7C8794;cursor:pointer;display:flex;align-items:center;justify-content:center')}>
)}
))}
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) => (
this.setUMPerm(k, e.target.checked)} style={sx('width:15px;height:15px;accent-color:#E03C31')} />
{label}
);
return (
Acesso
{m.id == null ? 'Novo usuário' : 'Editar usuário'}
this.setState({ userModal: null })} style={sx(BTN_CANCEL)}>Cancelar
this.salvarUsuario()} className="hv-red" style={sx('flex:1.4;background:#E03C31;color:#fff;border:none;border-radius:10px;padding:11px;font-size:13px;font-weight:700;cursor:pointer')}>{m.id == null ? 'Criar usuário' : 'Salvar alterações'}
);
}
/* ---------- 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)}
this.setPagamento('mode', 'total')} style={sx(modoBtn(!parcial))}>
Pago total
Quita os {this.fmt(falta)}
this.setPagamento('mode', 'parcial')} style={sx(modoBtn(parcial))}>
Pago parcial
Recebe parte · resto pendente
{parcial && (
Valor recebido agora (R$)
this.setPagamento('valor', e.target.value)} placeholder="0,00" className="kes-in" style={sx(IN13)} />
{okValor ? (add > 0 ? 'Ficará pendente: ' + this.fmt(restante) : 'Informe o valor recebido') : 'O valor não pode passar de ' + this.fmt(falta)}
)}
this.closePagamento()} style={sx(BTN_CANCEL)}>Cancelar
this.registrarPagamento()} style={sx(`flex:1.4;background:${okValor && add > 0 ? '#2BB673' : '#2a3a33'};color:${okValor && add > 0 ? '#fff' : '#7C8794'};border:none;border-radius:10px;padding:11px;font-size:13px;font-weight:700;cursor:${okValor && add > 0 ? 'pointer' : 'not-allowed'}`)}>{parcial ? 'Registrar parcial' : 'Registrar pagamento'}
);
}
/* ---------- 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)}
this.setPagarDesp('mode', 'total')} style={sx(modoBtn(!parcial))}>
Pago total
Quita os {this.fmt(falta)}
this.setPagarDesp('mode', 'parcial')} style={sx(modoBtn(parcial))}>
Pago parcial
Paga parte · resto pendente
{parcial && (
Valor pago agora (R$)
this.setPagarDesp('valor', ev.target.value)} placeholder="0,00" className="kes-in" style={sx(IN13)} />
{okValor ? (add > 0 ? 'Ficará pendente: ' + this.fmt(restante) : 'Informe o valor pago') : 'O valor não pode passar de ' + this.fmt(falta)}
)}
this.closePagarDesp()} style={sx(BTN_CANCEL)}>Cancelar
this.registrarPagamentoDespesa()} className="hv-red" style={sx(`flex:1.4;background:${okValor && add > 0 ? '#E03C31' : '#3a2a28'};color:${okValor && add > 0 ? '#fff' : '#7C8794'};border:none;border-radius:10px;padding:11px;font-size:13px;font-weight:700;cursor:${okValor && add > 0 ? 'pointer' : 'not-allowed'}`)}>{parcial ? 'Registrar parcial' : 'Marcar pago'}
);
}
/* ---------- 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
this.setClienteModal('type', 'PJ')} style={sx(tStyle(isPJ))}>Pessoa Jurídica
this.setClienteModal('type', 'PF')} style={sx(tStyle(!isPJ))}>Pessoa Física
{isPJ ? 'Razão social' : 'Nome completo'}
this.setClienteModal('name', e.target.value)} className="kes-in" style={sx(IN13)} />
{isPJ ? 'CNPJ' : 'CPF'}{docOk ? ✓ : null}
this.setClienteModal('doc', isPJ ? mCNPJ(e.target.value) : mCPF(e.target.value))} className="kes-in" style={sx(IN13)} />
Telefone / WhatsApp
this.setClienteModal('phone', mFone(e.target.value))} className="kes-in" style={sx(IN13)} />
E-mail
this.setClienteModal('email', e.target.value)} className="kes-in" style={sx(IN13)} />
Serviço
this.setClienteModal('service', e.target.value)} className="kes-in" style={sx(IN13)} />
Valor mensal (R$)
this.setClienteModal('monthly', e.target.value)} className="kes-in" style={sx(IN13)} />
Dia de vencimento
this.setClienteModal('payDay', e.target.value)} className="kes-in" style={sx(IN13)}>{Array.from({ length: 31 }, (_, i) => String(i + 1)).map(d => {d} )}
Status
this.setClienteModal('status', e.target.value)} className="kes-in" style={sx(IN13)}>
Ativo
Inadimplente
Inativo / Saída
Endereço
{!!m.erro &&
{m.erro}
}
this.setState({ clienteModal: null })} style={sx(BTN_CANCEL)}>Cancelar
this.saveCliente()} className="hv-red" style={sx('flex:1.4;background:#E03C31;color:#fff;border:none;border-radius:10px;padding:11px;font-size:13px;font-weight:700;cursor:pointer')}>Salvar alterações
);
}
/* ---------- 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
this.setState({ exportOpen: false })} className="hv-close" style={sx('width:30px;height:30px;border-radius:8px;background:#0E1318;border:1px solid rgba(255,255,255,.1);color:#B7C0CB;cursor:pointer;display:flex;align-items:center;justify-content:center')}>
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]) => (
this.exportar(k)} className="hv-ghost" style={sx('display:flex;align-items:center;justify-content:space-between;gap:10px;background:#0E1318;color:#fff;border:1px solid rgba(255,255,255,.09);border-radius:11px;padding:12px 15px;cursor:pointer;font-family:inherit;text-align:left')}>
{titulo}
{desc}
))}
this.exportar('backup')} className="hv-red" style={sx('display:flex;align-items:center;justify-content:center;gap:8px;background:#E03C31;color:#fff;border:none;border-radius:11px;padding:12px;font-size:13px;font-weight:700;cursor:pointer;margin-top:6px')}>
Backup completo (.json) — todos os dados
);
}
/* ---------- 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…'}
this.carregarStatus()} className="hv-ghost" style={sx(btnT)}>Verificar de novo
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.
Razão social
this.setEmp('razao', e.target.value)} className="kes-in" style={sx(IN13)} />
CNPJ {this.state.empresa.cnpj && validaCNPJ(this.state.empresa.cnpj) ? ✓ : null}
this.setEmp('cnpj', mCNPJ(e.target.value))} placeholder="00.000.000/0001-00" className="kes-in" style={sx(IN13)} />
Inscrição municipal
this.setEmp('im', e.target.value)} placeholder="número na prefeitura" className="kes-in" style={sx(IN13)} />
Regime tributário
this.setEmp('regime', e.target.value)} className="kes-in" style={sx(IN13)}>
MEI
Simples Nacional
Outro
E-mail da empresa
this.setEmp('email', e.target.value)} className="kes-in" style={sx(IN13)} />
Código de tributação nacional do serviço
this.setEmp('codTrib', e.target.value)} placeholder="ex.: 17.06.01 — copie de uma nota emitida" className="kes-in" style={sx(IN13)} />
Alíquota de ISS (%)
this.setEmp('aliquota', e.target.value.replace(/[^\d,\.]/g, ''))} placeholder="ex.: 2,00" className="kes-in" style={sx(IN13)} />
CEP
this.setEmp('cep', mCEP(e.target.value))} placeholder="95000-000" className="kes-in" style={sx(IN13)} />
Bairro
this.setEmp('bairro', e.target.value)} className="kes-in" style={sx(IN13)} />
Endereço e número
this.setEmp('endereco', e.target.value)} placeholder="Rua Exemplo" className="kes-in" style={sx(IN13 + ';flex:1')} />
this.setEmp('numero', e.target.value)} placeholder="Nº" className="kes-in" style={sx(IN13 + ';width:90px')} />
Cidade
this.setEmp('cidade', e.target.value)} className="kes-in" style={sx(IN13)} />
UF · Código IBGE do município
this.setEmp('uf', e.target.value.toUpperCase().slice(0, 2))} placeholder="RS" className="kes-in" style={sx(IN13 + ';width:70px')} />
this.setEmp('codIbge', e.target.value.replace(/\D/g, '').slice(0, 7))} placeholder="4305108 (Caxias do Sul)" className="kes-in" style={sx(IN13 + ';flex:1')} />
{/* 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:
Criar conta em autentique.com.br (a gratuita já serve).
No painel: Perfil → API keys (painel.autentique.com.br/perfil/api) → gerar o token e colar abaixo.
Colar o token abaixo, salvar e clicar em Testar conexão .
Token da API do Autentique {api && api.autentique.configurado ? ('· salvo: ' + api.autentique.tokenMascarado) : ''}
this.setInt('autentiqueToken', e.target.value)} placeholder={api && api.autentique.configurado ? 'já configurado — cole aqui só para trocar' : 'cole o token aqui'} className="kes-in" style={sx(IN13)} />
this.salvarIntegracao({ autentiqueToken: fi.autentiqueToken })} disabled={!fi.autentiqueToken} className="hv-red" style={sx(btnS + (fi.autentiqueToken ? '' : ';opacity:.45;cursor:not-allowed'))}>Salvar
this.testarIntegracao('autentique')} className="hv-ghost" style={sx(btnT)}>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:
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.
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).
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).
Caminho do certificado A1 (.pfx)
this.setInt('nfsePfx', e.target.value)} placeholder={'Ex.: C:\\Certificados\\empresa.pfx'} className="kes-in" style={sx(IN13)} />
Senha do certificado {api && api.nfse.temSenha ? '· salva' : ''}
this.setInt('nfseSenha', e.target.value)} placeholder="••••••••" className="kes-in" style={sx(IN13)} />
this.setInt('nfseAmbiente', 'homologacao')} style={sx(ambBtn(fi.nfseAmbiente === 'homologacao'))}>Homologação (testes)
this.setInt('nfseAmbiente', 'producao')} style={sx(ambBtn(fi.nfseAmbiente === 'producao'))}>Produção
this.salvarIntegracao({ nfsePfx: fi.nfsePfx, nfseSenha: fi.nfseSenha, nfseAmbiente: fi.nfseAmbiente })} disabled={!fi.nfsePfx} className="hv-red" style={sx(btnS + (fi.nfsePfx ? '' : ';opacity:.45;cursor:not-allowed'))}>Salvar
this.testarIntegracao('nfse')} className="hv-ghost" style={sx(btnT)}>Testar conexão
{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:
Criar a conta em asaas.com (grátis; MEI usa o CCMEI; aprovação em até 2 dias úteis).
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.
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.
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( );