'); w.document.open(); w.document.write(htmlComPrint); w.document.close(); toast('Janela de impressão aberta. Em "Destino" escolha "Salvar como PDF".', 'info'); } async function ia_baixarPdf() { let prep = null; try { prep = await _iaPdfPrep(); await _iaPdfWorker(prep.content).save(); } catch (e) { console.error(e); toast('Erro ao gerar PDF: ' + (e && e.message ? e.message : e), 'danger'); } finally { if (prep) prep.cleanup(); } } // Baixa a peça gerada como DOCX (Word). Usa html-docx-js (carregado via CDN // no
). Conversão pura de formato no cliente — ZERO custo de IA. async function ia_baixarDocx() { try { if (!_iaPeticaoHtml) { toast('Gere a petição primeiro.', 'danger'); return; } if (typeof window.htmlDocx === 'undefined' || !window.htmlDocx.asBlob) { throw new Error('Biblioteca html-docx-js não carregou. Verifique a conexão e recarregue (Ctrl+F5).'); } // html-docx-js precisa de um HTML "limpo" — usa o documento já renderizado no iframe // pra garantir que CSS esteja resolvido. const blob = window.htmlDocx.asBlob(_iaPeticaoHtml, { orientation: 'portrait', margins: { top: 1440, right: 1440, bottom: 1440, left: 1440 } // 1in = 1440 twips }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); a.href = url; a.download = `peticao-ia-${stamp}.docx`; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1500); toast('DOCX baixado. Abra no Word/LibreOffice/Google Docs.', 'success'); } catch (e) { console.error(e); toast('Erro ao gerar DOCX: ' + (e && e.message ? e.message : e), 'danger'); } } async function ia_anexarPeticao() { const processoId = _iaPeticaoProcId; const btn = document.getElementById('ia_btnAnexar'); try { if (btn) { btn.disabled = true; btn.textContent = 'Anexando...'; } const prep = await _iaPdfPrep(); let blob; try { blob = await _iaPdfWorker(prep.content).outputPdf('blob'); } finally { prep.cleanup(); } if (!blob || blob.size < 5000) throw new Error('PDF vazio (' + (blob ? blob.size : 0) + ' bytes). Tente "Baixar PDF" primeiro pra ver se renderiza.'); const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); const nome = `peticao-ia-${stamp}.pdf`; const storagePath = `${processoId}/${Date.now()}-${nome}`; const { error: errUp } = await sb.storage.from(ANEXOS_BUCKET).upload(storagePath, blob, { cacheControl: '3600', upsert: false, contentType: 'application/pdf', }); if (errUp) throw new Error('Storage: ' + errUp.message); const id = await dbAdd('documentos', { processoId, nome, descricao: '[IA] Rascunho de petição gerado pela IA — REVISAR antes de protocolar.', tipo: 'Petição', storagePath, tamanhoBytes: blob.size, mimeType: 'application/pdf', criadoEm: new Date().toISOString(), atualizadoEm: new Date().toISOString(), }); if (!id) { await sb.storage.from(ANEXOS_BUCKET).remove([storagePath]); throw new Error('Falha ao salvar metadados.'); } toast('Petição anexada ao processo (aba/botão Anexos).', 'success'); } catch (e) { console.error(e); toast('Erro ao anexar: ' + (e && e.message ? e.message : e), 'danger'); } finally { if (btn) { btn.disabled = false; btn.textContent = '📎 Anexar ao processo'; } } } /* ============================== FILA DE PROTOCOLO (Fase 3b.7) — o app SÓ enfileira. O protocolo é o script local (autopet) que o George roda, com a trava de segurança e a revisão dos PDFs antes de assinar. ============================== */ async function ia_enviarProtocolo() { const processoId = _iaPeticaoProcId; const btn = document.getElementById('ia_btnProtocolo'); if (!_iaPeticaoHtml) { toast('Gere a petição primeiro.', 'danger'); return; } try { if (btn) { btn.disabled = true; btn.textContent = 'Enviando...'; } const p = await dbGet('processos', processoId); const cliente = p?.clienteId ? await dbGet('clientes', p.clienteId) : null; const pubId = document.getElementById('ia_pub')?.value || null; const comando = (document.getElementById('ia_cmd')?.value || '').trim(); // NOVA ARQUITETURA (Fase 5g): em vez de gerar PDF client-side (html2pdf // corrompia layout em peças complexas), enviamos o HTML BRUTO pro Storage // e o watcher local renderiza via Chrome headless (Page.printToPDF nativo). // Resultado: PDF 100% idêntico ao preview, sem retrabalho pro advogado. if (!_iaPeticaoHtml || _iaPeticaoHtml.length < 200) { throw new Error('HTML da petição vazio. Gere a peça primeiro.'); } const blob = new Blob([_iaPeticaoHtml], { type: 'text/html;charset=utf-8' }); const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); const nome = `peticao-html-${stamp}.html`; const storagePath = `${processoId}/${Date.now()}-${nome}`; const { error: errUp } = await sb.storage.from(ANEXOS_BUCKET).upload(storagePath, blob, { cacheControl: '3600', upsert: false, contentType: 'text/html; charset=utf-8', }); if (errUp) throw new Error('Storage: ' + errUp.message); const numMask = p?.numero || ''; // Auto-detect tribunal/sistema pelo NPU pra watcher_protocolo rotear correto: // .8.26. = TJSP eSAJ (default) .8.24. = TJSC eproc const isTjsp = /^\d{7}-\d{2}\.\d{4}\.8\.26\.\d{4}$/.test(numMask); const isTjsc = /^\d{7}-\d{2}\.\d{4}\.8\.24\.\d{4}$/.test(numMask); const tribunalDetectado = isTjsp ? 'TJSP' : (isTjsc ? 'TJSC' : (p?.tribunal || null)); const sistemaDetectado = isTjsp ? 'esaj' : (isTjsc ? 'eproc-tjsc' : null); const { error: errIns } = await sb.from('protocolo_fila').insert([{ tipo: 'protocolo', processo_id: processoId, publicacao_id: pubId, numero_mascara: numMask, numero_processo: numMask.replace(/\D/g, ''), grau: '1g', nome_parte: cliente?.nome || null, tribunal: tribunalDetectado, sistema: sistemaDetectado, arquivo_path: storagePath, // ← HTML; o watcher converte pra PDF e atualiza este campo arquivo_nome: nome, status: 'aguardando_pdf', // ← watcher local renderiza PDF via Chrome :9222 + muda pra aguardando_revisao observacao: comando ? ('Comando IA: ' + comando).slice(0, 500) : null, }]); if (errIns) { await sb.storage.from(ANEXOS_BUCKET).remove([storagePath]); throw new Error(errIns.message); } toast('Petição enviada. O watcher local vai gerar o PDF perfeito em ~10s. Acompanhe em Protocolo.', 'success'); } catch (e) { console.error(e); toast('Erro ao enviar p/ protocolo: ' + (e && e.message ? e.message : e), 'danger'); } finally { if (btn) { btn.disabled = false; btn.textContent = '📤 Enviar p/ protocolo'; } } } // Pedido de íntegra direto da ficha do processo (sem precisar de publicação). // Cria row em protocolo_fila tipo='integra', status='aguardando_integra' — // o watcher_integra.js (rodando no PC) detecta em até 15s e baixa o PDF // unificado via autopet_integra_esaj_tjsp.js. async function pedirIntegraProcesso(procId) { try { const p = await dbGet('processos', procId); if (!p) { toast('Processo não encontrado', 'error'); return; } const numMask = p.numero || ''; const npuDig = numMask.replace(/\D/g, ''); // NPU: XXXXXXX-DD.YYYY.J.TR.OOOO → J=pos13, TR=pos14-15 // TJSP = J=8 TR=26 → "826", TJSC = J=8 TR=24 → "824" const isTjsp = npuDig.length === 20 && npuDig.substring(13, 16) === '826'; const isTjsc = npuDig.length === 20 && npuDig.substring(13, 16) === '824'; const isEsaj = (p.sistema || 'esaj') === 'esaj'; if (!((isTjsp && isEsaj) || isTjsc)) { toast('Por enquanto só TJSP eSAJ e TJSC eproc têm download automático.', 'danger'); return; } // Trava: já existe pedido pendente? const { data: jaFila } = await sb.from('protocolo_fila') .select('id,status') .eq('tipo', 'integra') .eq('processo_id', procId) .eq('status', 'aguardando_integra'); if (jaFila && jaFila.length) { toast('Já existe um pedido de íntegra pendente para esse processo.', 'warning'); return; } const cliente = p.clienteId ? await dbGet('clientes', p.clienteId) : null; const { error } = await sb.from('protocolo_fila').insert([{ tipo: 'integra', processo_id: procId, numero_mascara: numMask, numero_processo: npuDig, grau: '1g', nome_parte: cliente?.nome || null, tribunal: isTjsc ? 'TJSC' : (p.tribunal || 'TJSP'), sistema: isTjsc ? 'eproc-tjsc' : 'esaj', // <-- watcher procura 'eproc-tjsc' (nao 'eproc') status: 'aguardando_integra', observacao: isTjsc ? 'Pedido TJSC eproc 1g — integra_eproc_tjsc.js (Playwright + Download Completo).' : 'Pedido direto da ficha do processo.', }]); if (error) throw new Error(error.message); toast('Pedido de íntegra enfileirado. O watcher local baixa em até 15s.', 'success'); } catch (e) { console.error(e); toast('Erro ao pedir íntegra: ' + (e && e.message ? e.message : e), 'danger'); } } // Solicita ao watcher local re-analisar a íntegra (com IA) de um processo. // Útil quando a íntegra já foi baixada antes mas o cache de análise está // desatualizado, ou pra forçar refresh depois de movimentação processual. async function pedirReanaliseProcesso(procId) { try { const p = await dbGet('processos', procId); if (!p) { toast('Processo não encontrado', 'error'); return; } // Verifica se já tem íntegra baixada const { data: docs } = await sb.from('documentos') .select('id,nome,created_at') .eq('processo_id', procId) .ilike('descricao', '%Íntegra%') .order('created_at', { ascending: false }) .limit(1); if (!docs || !docs.length) { toast('Esse processo ainda não tem íntegra baixada. Clique "📥 Íntegra" primeiro.', 'warning'); return; } // Verifica se já tem análise (apenas pra informar) const { data: analise } = await sb.from('processo_analise_ia') .select('atualizado_em, custo_usd') .eq('processo_id', procId) .maybeSingle(); const msgConfirma = analise ? `Já existe análise IA desse processo (atualizada em ${(analise.atualizado_em || '').slice(0,10)}). Refazer custa ~$1,30 em tokens Anthropic. Continuar?` : `Vai analisar a íntegra com IA. Custo estimado: ~$1,30. Continuar?`; if (!confirm(msgConfirma)) return; // Já existe pedido pendente? const { data: jaFila } = await sb.from('protocolo_fila') .select('id,status') .eq('tipo', 'reanalisar') .eq('processo_id', procId) .eq('status', 'aguardando_analise'); if (jaFila && jaFila.length) { toast('Já existe um pedido de reanálise pendente para esse processo.', 'warning'); return; } const cliente = p.clienteId ? await dbGet('clientes', p.clienteId) : null; const { error } = await sb.from('protocolo_fila').insert([{ tipo: 'reanalisar', processo_id: procId, numero_mascara: p.numero, numero_processo: (p.numero || '').replace(/\D/g, ''), nome_parte: cliente?.nome || null, tribunal: p.tribunal || 'TJSP', sistema: 'esaj', status: 'aguardando_analise', observacao: 'Pedido manual de reanálise via card do processo.', }]); if (error) throw new Error(error.message); toast('Reanálise enfileirada. O watcher local processa em até 15s.', 'success'); } catch (e) { console.error(e); toast('Erro ao pedir reanálise: ' + (e && e.message ? e.message : e), 'danger'); } } async function iaPedirIntegra(pubId) { const pub = (_pubsCache || []).find(x => x.id === pubId) || await dbGet('publicacoes', pubId); if (!pub) { toast('Publicação não encontrada', 'error'); return; } const procId = pub.processoId || pub.processo_id; if (!procId) { toast('Vincule a publicação a um processo primeiro.', 'danger'); return; } try { const p = await dbGet('processos', procId); const cliente = p?.clienteId ? await dbGet('clientes', p.clienteId) : null; const numMask = p?.numero || pub.numeroProcessoMascara || pub.numero_processo_mascara || ''; // Auto-detecta tribunal/sistema pelo NPU pra watcher rotear correto // NPU 20 digitos: XXXXXXX-DD.YYYY.J.TR.OOOO → JTR (pos 13-15) const npuDig = numMask.replace(/\D/g, ''); const jtr = npuDig.length === 20 ? npuDig.substring(13, 16) : ''; const isTjsc = jtr === '824'; const isTjsp = jtr === '826'; const tribunal = isTjsc ? 'TJSC' : (isTjsp ? 'TJSP' : (p?.tribunal || pub.tribunal || null)); // sistema = 'eproc-tjsc' p/ TJSC, 'esaj' p/ TJSP (watcher historico), null caso contrario const sistema = isTjsc ? 'eproc-tjsc' : (isTjsp ? 'esaj' : null); const { error } = await sb.from('protocolo_fila').insert([{ tipo: 'integra', processo_id: procId, publicacao_id: pubId, numero_mascara: numMask, numero_processo: npuDig, grau: '1g', nome_parte: cliente?.nome || null, tribunal, sistema, // <-- ANTES estava ausente → watcher nao roteava status: 'aguardando_integra', observacao: 'Pedido de baixar os autos (íntegra) p/ análise.', }]); if (error) throw new Error(error.message); toast('Pedido de íntegra enfileirado (aba Protocolo). O script local baixará os autos.', 'success'); } catch (e) { console.error(e); toast('Erro ao pedir íntegra: ' + (e && e.message ? e.message : e), 'danger'); } } const _PROTO_STATUS = { aguardando_revisao: ['warning', 'aguardando revisão'], aprovado: ['warning', 'aprovado — watcher prepara'], reprovado: ['danger', 'reprovado'], protocolado: ['success', '✓ protocolado'], erro: ['danger', 'erro'], aguardando_integra: ['warning', 'aguardando íntegra'], integra_baixada: ['success', 'íntegra baixada'], aguardando_analise: ['warning', 'aguardando análise IA'], analise_completa: ['success', 'análise IA concluída'], aguardando_cadastro: ['warning', 'aguardando cadastro'], cadastro_completo: ['success', 'cadastro completo'], aguardando_assinatura: ['warning', '⏳ pronto pra assinar (PIN A3)'], }; let _protoRealtimeCh = null; views.protocolo = async (main) => { // Supabase Realtime: assina mudanças na protocolo_fila e re-renderiza // instantaneamente quando watcher atualiza algum item. Substitui polling. if (_protoRealtimeCh) { try { sb.removeChannel(_protoRealtimeCh); } catch(e){} _protoRealtimeCh = null; } _protoRealtimeCh = sb.channel('protocolo-fila-rt') .on('postgres_changes', { event: '*', schema: 'public', table: 'protocolo_fila' }, async () => { const navBtn = document.querySelector('.nav-btn.active[data-view="protocolo"]'); if (!navBtn) { try { sb.removeChannel(_protoRealtimeCh); } catch(e){} _protoRealtimeCh = null; return; } const tb = document.getElementById('tbProto'); if (!tb) return; const { data } = await sb.from('protocolo_fila').select('*').order('created_at', { ascending: false }); _protoRenderTabela(tb, data || []); }) .subscribe(); const { data, error } = await sb.from('protocolo_fila').select('*').order('created_at', { ascending: false }); const fila = error ? [] : (data || []); const aprovados = fila.filter(r => r.tipo === 'protocolo' && r.status === 'aprovado').length; main.innerHTML = `
${fila.length} item(ns) · ${aprovados} aprovado(s) prontos p/ o script local
watcher_protocolo.js (rodando no PC) detecta em ≤15s, baixa o PDF, abre a aba do tribunal certo no Chrome :9222 e marca ⏳ pronto pra assinar;
(3) Você vai na aba aberta, faz upload do PDF, classifica e assina com PIN A3;
(4) Volta aqui e clica "Já protocolei".
`; _protoRenderTabela(document.getElementById('tbProto'), fila); }; function _protoRenderTabela(el, fila) { if (!el) return; if (!fila.length) { el.innerHTML = `
Gere uma petição (🤖 IA) e clique "Enviar p/ protocolo", ou peça a íntegra numa publicação.
`; return; } el.innerHTML = `
| Tipo | Processo | Parte (polo ativo) | Grau | Sistema | Status | |
|---|---|---|---|---|---|---|
| ${r.tipo === 'integra' ? '📥 íntegra' : (r.tipo === 'reanalisar' ? '🧠 reanálise' : '📤 peça')} | ${escape(r.numero_mascara || r.numero_processo || '-')}${r.observacao ? ` ${escape((r.observacao || '').slice(0, 90))}` : ''} |
${escape(r.nome_parte || '-')} | ${grauCel} | ${sysCel} | ${escape(st[1])} | ${acoes} |
`; } async function protoCopiarCaminho(id) { const { data } = await sb.from('protocolo_fila').select('resultado').eq('id', id).single(); const path = data?.resultado?.arquivo_local || ''; if (!path) { toast('Caminho do PDF não disponível', 'danger'); return; } try { await navigator.clipboard.writeText(path); toast('Caminho copiado: ' + path, 'success'); } catch (e) { toast('Erro: ' + e.message, 'danger'); } } async function protoAbrirTribunal(id) { const { data } = await sb.from('protocolo_fila').select('resultado').eq('id', id).single(); const url = data?.resultado?.tribunal_url || ''; if (!url || url === 'about:blank') { toast('URL do tribunal não disponível', 'danger'); return; } window.open(url, '_blank'); } async function protoVerPdf(path, nome) { const { data, error } = await sb.storage.from(ANEXOS_BUCKET).createSignedUrl(path, 120, { download: nome }); if (error) { toast('Erro ao abrir PDF: ' + error.message, 'error'); return; } window.open(data.signedUrl, '_blank'); } async function protoAprovar(id) { const sel = document.getElementById('pg_' + id); const grau = sel ? sel.value : '1g'; const sysSel = document.getElementById('ps_' + id); const update = { status: 'aprovado', grau }; if (sysSel) update.sistema = sysSel.value; // só presente pra TJSP const { error } = await sb.from('protocolo_fila').update(update).eq('id', id); if (error) { toast('Erro: ' + error.message, 'danger'); return; } toast('Aprovado. Rode o script local p/ protocolar.', 'success'); renderView('protocolo'); } async function protoStatus(id, status) { const { error } = await sb.from('protocolo_fila').update({ status }).eq('id', id); if (error) { toast('Erro: ' + error.message, 'danger'); return; } renderView('protocolo'); } async function protoExcluir(id, path) { if (!confirm('Excluir este item da fila?')) return; if (path) { try { await sb.storage.from(ANEXOS_BUCKET).remove([path]); } catch (_) { /* ignore */ } } const { error } = await sb.from('protocolo_fila').delete().eq('id', id); if (error) { toast('Erro: ' + error.message, 'danger'); return; } toast('Item removido'); renderView('protocolo'); } // Publicações contextuais por processo (botão na linha da aba Processos do cliente) async function publicacoesProcesso(processoId) { const p = await dbGet('processos', processoId); if (!p) { toast('Processo não encontrado', 'error'); return; } const cliente = await dbGet('clientes', p.clienteId); // Busca publicações ligadas a este processo: via processo_id OU via numero_processo (sem máscara) const numSoDigitos = (p.numero || '').replace(/\D/g, ''); const todas = await dbGetAll('publicacoes'); const lista = todas.filter(x => x.processoId === processoId || (numSoDigitos && (x.numeroProcesso === numSoDigitos || x.numero_processo === numSoDigitos)) ).sort((a, b) => (b.dataDisponibilizacao || b.data_disponibilizacao || '').localeCompare(a.dataDisponibilizacao || a.data_disponibilizacao || '')); const tabela = lista.length ? `
| Data | Tribunal | Tipo | Resumo | |
|---|---|---|---|---|
| ${fmtDate(x.dataDisponibilizacao || x.data_disponibilizacao)} | ${escape(x.tribunal || '-')} | ${escape(x.tipoDocumento || x.tipo_documento || '-')} | ${escape(snippetTexto(x.texto, 120))} | ${!x.lida ? 'Nova' : ''} |
` : `
As publicações aparecem aqui quando o script puxar-publicacoes.js capturar uma do DJEN com o número CNJ que bate com este processo.
`; const body = `
${lista.length} publicação(ões) — mais recente em cima
`; openModal(`Publicações — Processo ${p.numero || '(sem nº)'}`, body, ''); } /* ============================== FINANCEIRO (Fase 3b.8) ============================== */ const _FIN_CATEGORIAS = { receita: ['Honorários', 'Êxito', 'Consulta', 'Contrato', 'Reembolso', 'Outros'], despesa: ['Custas processuais', 'Deslocamento', 'Salário', 'Aluguel', 'Internet/telefone', 'Material escritório', 'Software', 'Contador', 'Imposto', 'Outros'], }; const _FIN_FORMAS = ['PIX', 'Transferência', 'Dinheiro', 'Cartão de crédito', 'Cartão de débito', 'Boleto', 'Cheque']; const _FIN_STATUS = { pendente: ['warning', 'pendente'], pago: ['success', 'pago/recebido'], cancelado: ['danger', 'cancelado'], }; function _finFmtBrl(v) { const n = Number(v || 0); return n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); } function _finPrimeiroDiaMes(d) { const x = new Date(d); x.setDate(1); return x.toISOString().slice(0,10); } function _finUltimoDiaMes(d) { const x = new Date(d); x.setMonth(x.getMonth()+1); x.setDate(0); return x.toISOString().slice(0,10); } let _finFiltros = null; function _finFiltrosInit() { if (_finFiltros) return _finFiltros; const hoje = new Date(); _finFiltros = { de: _finPrimeiroDiaMes(hoje), ate: _finUltimoDiaMes(hoje), tipo: '', // '' | 'receita' | 'despesa' status: '', // '' | 'pendente' | 'pago' | 'cancelado' cliente_id: '', // '' | uuid }; return _finFiltros; } let _finAba = 'lancamentos'; // 'lancamentos' | 'porCliente' | 'porGrupo' /* ===================================================================== ABA INICIAL — Assistente IA pra petição inicial (Fase 5) Sub-tabs: 'nova' — Entrevista + IA gera inicial + viabilidade + docs 'citacao' — Cliente notificado: cola NPU → baixa + auto-cadastra 'calculadoras' — Calculadora trabalhista + previdenciária 'historico' — Lista de entrevistas anteriores ===================================================================== */ let _iniAba = 'nova'; let _iniEntrevistaAtual = null; // objeto da entrevista em edição let _iniRecognition = null; // SpeechRecognition handle pra áudio→texto const _INI_TIPOS_ACAO = [ 'Previdenciária', 'Trabalhista', 'Cível — Cobrança', 'Cível — Indenização', 'Cível — Consumidor', 'Família', 'Tributária', 'Mandado de Segurança', 'Outro', ]; /* ============================== RELATÓRIOS — visão executiva do escritório ============================== */ let _relAba = 'clientes'; function relSetAba(aba) { _relAba = aba; views.relatorios(document.getElementById('mainContent')); } views.relatorios = async (main) => { main.innerHTML = `
Visão executiva do escritório · período: mês corrente
`; const el = document.getElementById('relContent'); el.innerHTML = '
'; try { if (_relAba === 'clientes') await relRenderClientes(el); else if (_relAba === 'processos') await relRenderProcessos(el); else if (_relAba === 'financeiro')await relRenderFinanceiro(el); else if (_relAba === 'ia') await relRenderIA(el); else if (_relAba === 'performance') await relRenderPerformance(el); } catch (e) { el.innerHTML = `
${escape(e.message || e)}
`; } }; // ----- Helper de cards ----- function relCard(label, value, meta, color) { return `
`; } // ----- 1) CLIENTES ----- async function relRenderClientes(el) { const clientes = await dbGetAll('clientes'); const processos = await dbGetAll('processos'); const hoje = new Date(); const mesIni = new Date(hoje.getFullYear(), hoje.getMonth(), 1).toISOString(); const ano = hoje.getFullYear(); const novosMes = clientes.filter(c => (c.criadoEm || '') >= mesIni).length; const novosAno = clientes.filter(c => (c.criadoEm || '').slice(0,4) === String(ano)).length; const pf = clientes.filter(c => (c.tipoPessoa || 'PF') === 'PF').length; const pj = clientes.filter(c => c.tipoPessoa === 'PJ').length; // Por grupo const grupos = await _getGrupos(); const grupoMap = new Map(grupos.map(g => [g.id, g.nome])); const porGrupo = new Map(); for (const c of clientes) { const key = c.grupoId || c.grupo_id || null; porGrupo.set(key, (porGrupo.get(key) || 0) + 1); } const porGrupoArr = [...porGrupo.entries()].map(([gid, q]) => ({ nome: gid ? (grupoMap.get(gid) || '— grupo removido —') : '— sem grupo —', qtd: q, })).sort((a,b) => b.qtd - a.qtd); // Top 10 mais ativos (com mais processos) const procsByCli = new Map(); for (const p of processos) procsByCli.set(p.clienteId, (procsByCli.get(p.clienteId) || 0) + 1); const topAtivos = clientes .map(c => ({c, q: procsByCli.get(c.id) || 0})) .filter(x => x.q > 0) .sort((a,b) => b.q - a.q) .slice(0, 10); el.innerHTML = `
| Grupo | Qtd | % |
|---|---|---|
| ${escape(g.nome)} | ${g.qtd} | ${((g.qtd/clientes.length)*100).toFixed(1)}% |
| Cliente | Processos |
|---|---|
| ${escape(x.c.nome)} | ${x.q} |
`; } // ----- 2) PROCESSOS ----- async function relRenderProcessos(el) { const processos = await dbGetAll('processos'); const total = processos.length; const ativos = processos.filter(p => p.status !== 'Encerrado' && p.ativo !== false).length; const encerrados = processos.filter(p => p.status === 'Encerrado').length; const arquivados = processos.filter(p => p.ativo === false).length; // Por tribunal (extrai do número CNJ — posições 14-16 são J.TR) const porTribunal = new Map(); for (const p of processos) { const num = (p.numero || '').replace(/\D/g, ''); if (num.length === 20) { const j = num.slice(13,14), tr = num.slice(14,16); const key = `J${j} · TR${tr}`; porTribunal.set(key, (porTribunal.get(key) || 0) + 1); } else { porTribunal.set('— sem número CNJ —', (porTribunal.get('— sem número CNJ —') || 0) + 1); } } const tribArr = [...porTribunal.entries()].map(([k,v]) => ({k,v})).sort((a,b) => b.v - a.v); // Por natureza/área const porNatureza = new Map(); for (const p of processos) { const k = p.natureza || '— sem natureza —'; porNatureza.set(k, (porNatureza.get(k) || 0) + 1); } const natArr = [...porNatureza.entries()].map(([k,v]) => ({k,v})).sort((a,b) => b.v - a.v); // Idade média const hoje = new Date(); const idades = processos.map(p => { if (!p.dataDistribuicao) return null; const d = new Date(p.dataDistribuicao); return Math.round((hoje - d) / 86400000); }).filter(x => x !== null); const idadeMed = idades.length ? Math.round(idades.reduce((s,x) => s+x, 0) / idades.length) : 0; el.innerHTML = `
| Tribunal (J·TR) | Qtd |
|---|---|
| ${escape(x.k)} | ${x.v} |
| Natureza | Qtd |
|---|---|
| ${escape(x.k)} | ${x.v} |
`; } // ----- 3) FINANCEIRO ----- async function relRenderFinanceiro(el) { // Lê tabela financeiro_lancamentos (se existir) + honorários let lanc = [], hon = []; try { const r = await sb.from('financeiro_lancamentos').select('*'); lanc = r.data || []; } catch {} try { hon = await dbGetAll('honorarios'); } catch {} const hoje = new Date(); const mesIniIso = new Date(hoje.getFullYear(), hoje.getMonth(), 1).toISOString().slice(0,10); const anoIniIso = new Date(hoje.getFullYear(), 0, 1).toISOString().slice(0,10); const receitaMes = lanc.filter(l => l.tipo === 'receita' && (l.data || '') >= mesIniIso).reduce((s,l) => s + Number(l.valor||0), 0); const despesaMes = lanc.filter(l => l.tipo === 'despesa' && (l.data || '') >= mesIniIso).reduce((s,l) => s + Number(l.valor||0), 0); const receitaAno = lanc.filter(l => l.tipo === 'receita' && (l.data || '') >= anoIniIso).reduce((s,l) => s + Number(l.valor||0), 0); const despesaAno = lanc.filter(l => l.tipo === 'despesa' && (l.data || '') >= anoIniIso).reduce((s,l) => s + Number(l.valor||0), 0); const ebitdaMes = receitaMes - despesaMes; const ebitdaAno = receitaAno - despesaAno; // Honorários a receber (não quitados) const honPendentes = hon.filter(h => !h.quitadoEm && !h.quitado_em); const valorPendente = honPendentes.reduce((s,h) => s + Number(h.valorTotal || h.valor_total || 0), 0); // Top clientes por receita (lança financeiro com clienteId/cliente_id) const porCli = new Map(); for (const l of lanc) { if (l.tipo !== 'receita') continue; const cid = l.cliente_id || l.clienteId; if (!cid) continue; porCli.set(cid, (porCli.get(cid) || 0) + Number(l.valor || 0)); } const clientes = await dbGetAll('clientes'); const cliMap = new Map(clientes.map(c => [c.id, c.nome])); const topCli = [...porCli.entries()] .map(([cid, v]) => ({nome: cliMap.get(cid) || '— excluído —', valor: v})) .sort((a,b) => b.valor - a.valor).slice(0, 10); const brl = (v) => 'R$ ' + Number(v||0).toFixed(2).replace('.', ','); el.innerHTML = `
| Cliente | Receita acumulada |
|---|---|
| ${escape(x.nome)} | ${brl(x.valor)} |
Use a aba Financeiro pra lançar receitas e despesas.
`; } // ----- 4) CUSTOS IA ----- async function relRenderIA(el) { const hoje = new Date(); const mesIni = new Date(hoje.getFullYear(), hoje.getMonth(), 1).toISOString(); const anoIni = new Date(hoje.getFullYear(), 0, 1).toISOString(); // 4 origens de custo: análises de processo, iniciais IA, classificação de docs, análise de publicações const [analises, iniciais, docs, pubsAna] = await Promise.all([ sb.from('processo_analise_ia').select('custo_usd,atualizado_em').then(r => r.data || []), sb.from('inicial_entrevistas').select('custo_usd,updated_at').then(r => r.data || []), sb.from('documentos').select('ia_custo_usd,ia_classificado_em').eq('classificado_por_ia', true).then(r => r.data || []), sb.from('publicacoes').select('ia_custo_usd,ia_analisada_em').not('ia_analisada_em','is',null).then(r => r.data || []), ]); const somar = (arr, fCusto, fData, depois) => arr .filter(x => !depois || (x[fData] || '') >= depois) .reduce((s,x) => s + Number(x[fCusto] || 0), 0); const linhas = [ { nome: '🔍 Análises de processo', mes: somar(analises, 'custo_usd', 'atualizado_em', mesIni), ano: somar(analises, 'custo_usd', 'atualizado_em', anoIni), total: somar(analises, 'custo_usd', 'atualizado_em', null), qtd: analises.length }, { nome: '📝 Petições iniciais', mes: somar(iniciais, 'custo_usd', 'updated_at', mesIni), ano: somar(iniciais, 'custo_usd', 'updated_at', anoIni), total: somar(iniciais, 'custo_usd', 'updated_at', null), qtd: iniciais.length }, { nome: '📎 Classificação docs', mes: somar(docs, 'ia_custo_usd', 'ia_classificado_em', mesIni), ano: somar(docs, 'ia_custo_usd', 'ia_classificado_em', anoIni), total: somar(docs, 'ia_custo_usd', 'ia_classificado_em', null), qtd: docs.length }, { nome: '📬 Análise publicações', mes: somar(pubsAna, 'ia_custo_usd', 'ia_analisada_em', mesIni), ano: somar(pubsAna, 'ia_custo_usd', 'ia_analisada_em', anoIni), total: somar(pubsAna, 'ia_custo_usd', 'ia_analisada_em', null), qtd: pubsAna.length }, ]; const totalMes = linhas.reduce((s,l) => s + l.mes, 0); const totalAno = linhas.reduce((s,l) => s + l.ano, 0); const totalAll = linhas.reduce((s,l) => s + l.total, 0); const totalQtd = linhas.reduce((s,l) => s + l.qtd, 0); const usd = (v) => 'US$ ' + Number(v||0).toFixed(4); const brl = (v) => 'R$ ' + (Number(v||0) * 6).toFixed(2).replace('.', ','); el.innerHTML = `
| Feature | Mês | Ano | Total | Qtd chamadas | Médio |
|---|---|---|---|---|---|
| ${l.nome} | ${usd(l.mes)} | ${usd(l.ano)} | ${usd(l.total)} (${brl(l.total)}) | ${l.qtd} | ${usd(l.qtd ? l.total/l.qtd : 0)} |
| TOTAL | ${usd(totalMes)} | ${usd(totalAno)} | ${usd(totalAll)} (${brl(totalAll)}) | ${totalQtd} | — |
Câmbio aproximado USD→BRL: 6,00 · valores reais cobrados em USD pela Anthropic
`; } // ----- 5) PERFORMANCE ----- async function relRenderPerformance(el) { const eventos = await dbGetAll('agenda_eventos'); const andamentos = await dbGetAll('andamentos'); const processos = await dbGetAll('processos'); const hoje = new Date().toISOString().slice(0,10); // Prazos: concluídos vs atrasados vs em dia const prazos = eventos.filter(e => e.tipo === 'Prazo'); const prazosConcluidos = prazos.filter(e => e.concluido).length; const prazosAtrasados = prazos.filter(e => !e.concluido && e.data < hoje).length; const prazosEmDia = prazos.filter(e => !e.concluido && e.data >= hoje).length; const taxaCumprimento = prazos.length ? ((prazosConcluidos / (prazosConcluidos + prazosAtrasados || 1)) * 100) : 0; // Peças geradas (inicial_entrevistas com peça) const { data: inicGen } = await sb.from('inicial_entrevistas').select('id,created_at,status').eq('status', 'gerada'); const pecasGeradas = (inicGen || []).length; const mesIni = new Date().toISOString().slice(0,7) + '-01'; const pecasMes = (inicGen || []).filter(x => (x.created_at || '') >= mesIni).length; // Andamentos cadastrados (manual + automático) const andamentosMes = andamentos.filter(a => (a.data || '') >= mesIni).length; const auto = andamentos.filter(a => a.origem === 'auto' || a.fonte === 'datajud').length; // Audiências futuras const audiencias = eventos.filter(e => e.tipo === 'Audiência' && e.data >= hoje && !e.concluido); el.innerHTML = `
${audiencias.length ? `
| Data | Hora | Descrição | Cliente |
|---|---|---|---|
| ${fmtDate(e.data)} | ${escape(e.hora || '-')} | ${escape(e.descricao || '')} | ${escape(e.cliente || '-')} |
` : ''} `; } views.inicial = async (main) => { main.innerHTML = `
Elaboração de petição inicial, calculadoras preliminares e auto-cadastro quando o cliente é citado.
`; await _iniRenderAba(); }; function iniSetAba(aba) { _iniAba = aba; views.inicial(document.getElementById('mainContent')); } // Detecta a área jurídica a partir do tipo da ação selecionada ou do texto // da entrevista. Heurística simples; o detalhamento fino fica pra IA depois. function iniDetectarArea() { const tipo = (document.getElementById('ini_tipo_acao')?.value || '').toLowerCase(); const texto = (document.getElementById('ini_entrevista')?.value || '').toLowerCase(); const blob = tipo + ' ' + texto; if (/trabalh|clt|reclam|verba|hora.?extra|fgts|demiss|rescis/i.test(blob)) return 'trabalhista'; if (/consum|cdc|fornecedor|produto|servi[çc]o.*defeit|cobran[çc]a.*indev|tim |claro|vivo|oi |banco/i.test(blob)) return 'consumidor'; if (/previdenci|inss|aposentad|benef[ií]cio|auxíli|invalidez|loas|bpc/i.test(blob)) return 'previdenciario'; if (/divor|guarda|alimen|pens|fam[ií]l|inventári|sucess/i.test(blob)) return 'familia'; return 'civel'; } // Verifica que documentos o cliente já tem cadastrado vs. quais são esperados // pela área jurídica. Abre modal de checklist com opção de pedir docs faltantes // via WhatsApp (texto pré-pronto). async function iniVerificarDocs() { const clienteId = document.getElementById('ini_cliente')?.value; if (!clienteId) { toast('Selecione um cliente primeiro', 'danger'); return; } const c = await dbGet('clientes', clienteId); if (!c) { toast('Cliente não encontrado', 'danger'); return; } const area = iniDetectarArea(); const obrigatorios = DOCS_OBRIG_POR_AREA[area] || DOCS_OBRIG_PADRAO; // Busca docs do cliente (direto + via processos) const { data: docsCli } = await sb.from('documentos').select('tipo,nome,classificado_por_ia,confianca_ia').eq('cliente_id', clienteId); const procs = (await dbGetAll('processos')).filter(p => p.clienteId === clienteId); const procIds = procs.map(p => p.id); const { data: docsProc } = procIds.length ? await sb.from('documentos').select('tipo,nome').in('processo_id', procIds) : { data: [] }; const todos = [...(docsCli||[]), ...(docsProc||[])]; const tiposPresentes = new Set(todos.map(d => d.tipo)); // Checklist const checklist = obrigatorios.map(tipo => { const tem = tiposPresentes.has(tipo); return { tipo, tem }; }); const faltantes = checklist.filter(c => !c.tem).map(c => c.tipo); const presentes = checklist.filter(c => c.tem).map(c => c.tipo); // Texto pré-pronto pro WhatsApp se faltar const docsTxt = faltantes.map(t => `• ${t}`).join('\n'); const mensagemWa = `Olá ${c.nome?.split(' ')[0] || ''}! Aqui é o Dr. George.\n\n` + `Estou preparando sua ação${area !== 'civel' ? ' ' + area : ''} e preciso destes documentos pra anexar:\n\n` + `${docsTxt}\n\n` + `Pode me enviar por aqui mesmo? Pode ser foto da câmera, sem problema. Qualquer dúvida me fala.\n\n` + `Obrigado!`; const corCabec = faltantes.length === 0 ? '#10B981' : '#EF4444'; const tituloCabec = faltantes.length === 0 ? '✅ Documentação completa' : `⚠ Faltam ${faltantes.length} documento${faltantes.length===1?'':'s'}`; const body = `
${faltantes.length > 0 ? `
` : `
`} `; openModal('Checklist de documentos para inicial', body, ''); } function iniMandarWaDocs(celular) { if (!celular) { toast('Cliente sem celular cadastrado', 'danger'); return; } const msg = document.getElementById('ini_msg_wa')?.value || ''; abrirWhatsApp(celular, msg); } async function _iniRenderAba() { const c = document.getElementById('iniConteudo'); if (_iniAba === 'nova') c.innerHTML = await _iniRenderNovaPeticao(); else if (_iniAba === 'citacao') c.innerHTML = await _iniRenderCitacao(); else if (_iniAba === 'calculadoras') c.innerHTML = _iniRenderCalculadoras(); else if (_iniAba === 'historico') c.innerHTML = await _iniRenderHistorico(); } /* ---- Sub-tab: NOVA PETIÇÃO INICIAL ---- */ async function _iniRenderNovaPeticao() { const clientes = (await dbGetAll('clientes')).sort((a,b)=>(a.nome||'').localeCompare(b.nome||'')); return `
Selecione o cliente, descreva a situação, e a IA gera a peça + análise de viabilidade + lista de documentos.
Descreva o caso por texto OU use o microfone pra ditar. Mínimo 50 caracteres.
Se quiser anexar cálculo trabalhista ou previdenciário, rode na aba 🧮 Calculadoras e cole o JSON aqui.
`; } // Quando o usuário digita/seleciona no autocomplete, faz match contra clientes async function iniSelecionarClientePorNome(nome) { const status = document.getElementById('ini_cliente_status'); const hidden = document.getElementById('ini_cliente'); if (!nome || nome.trim().length < 2) { hidden.value = ''; if (status) { status.textContent = 'Digite ao menos 2 letras pra buscar.'; status.style.color = 'var(--text-muted)'; } return; } const clientes = await dbGetAll('clientes'); const nomeLower = _semAcento(nome.trim()); const exato = clientes.find(c => _semAcento(c.nome) === nomeLower); if (exato) { hidden.value = exato.id; if (status) { status.innerHTML = '✓ Cliente: ' + escape(exato.nome) + '' + (exato.cpf ? ' · CPF ' + escape(exato.cpf) : '') + (exato.celular ? ' · ' + escape(exato.celular) : ''); status.style.color = 'var(--success)'; } return; } // Match parcial (tolerante a acentos) const matches = clientes.filter(c => _semAcento(c.nome).includes(nomeLower)).slice(0, 5); if (matches.length === 0) { hidden.value = ''; if (status) { status.innerHTML = '⚠ Nenhum cliente com esse nome. Cadastre primeiro.'; status.style.color = 'var(--warning)'; } } else if (matches.length === 1) { hidden.value = matches[0].id; if (status) { status.innerHTML = '✓ Encontrado: ' + escape(matches[0].nome) + '' + (matches[0].cpf ? ' · CPF ' + escape(matches[0].cpf) : ''); status.style.color = 'var(--success)'; } } else { hidden.value = ''; if (status) { status.innerHTML = matches.length + ' clientes encontrados. Complete o nome pra selecionar um.'; status.style.color = 'var(--text-muted)'; } } } /* Web Speech API: áudio → texto direto no navegador, sem custo Anthropic. Suporta Chrome/Edge. Usa pt-BR. */ function iniToggleAudio() { const btn = document.getElementById('ini_btn_audio'); const status = document.getElementById('ini_audio_status'); const txta = document.getElementById('ini_entrevista'); if (_iniRecognition) { // parando try { _iniRecognition.stop(); } catch (e) {} _iniRecognition = null; btn.textContent = '🎙️ Gravar áudio'; btn.classList.remove('btn-danger'); btn.classList.add('btn-secondary'); status.textContent = ''; return; } const SR = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SR) { toast('Seu navegador não suporta reconhecimento de voz (use Chrome/Edge)', 'danger'); return; } _iniRecognition = new SR(); _iniRecognition.lang = 'pt-BR'; _iniRecognition.continuous = true; _iniRecognition.interimResults = true; let baseTxt = txta.value; let acumulado = ''; _iniRecognition.onresult = (e) => { let interim = ''; for (let i = e.resultIndex; i < e.results.length; i++) { const r = e.results[i]; if (r.isFinal) acumulado += r[0].transcript + ' '; else interim += r[0].transcript; } txta.value = (baseTxt ? baseTxt + ' ' : '') + acumulado + interim; status.textContent = `🎙️ Gravando... ${acumulado.length + interim.length} chars transcritos`; }; _iniRecognition.onerror = (e) => { status.textContent = '⚠ Erro: ' + e.error; }; _iniRecognition.onend = () => { if (_iniRecognition) { try { _iniRecognition.start(); } catch (e) {} } // auto-restart }; _iniRecognition.start(); btn.textContent = '⏹ Parar gravação'; btn.classList.remove('btn-secondary'); btn.classList.add('btn-danger'); status.textContent = '🎙️ Gravando... fale claramente em português'; } async function iniGerarPeticao() { const clienteId = document.getElementById('ini_cliente').value; const tipo_acao = document.getElementById('ini_tipo_acao').value; const advogadoKey = document.getElementById('ini_advogado').value; const comarca = document.getElementById('ini_comarca').value.trim() || 'São Paulo'; const entrevista = document.getElementById('ini_entrevista').value.trim(); const calculoTxt = document.getElementById('ini_calculo').value.trim(); if (!clienteId) { toast('Escolha um cliente', 'danger'); return; } if (entrevista.length < 50) { toast('Entrevista muito curta (mínimo 50 caracteres)', 'danger'); return; } let calculo_preliminar = null; if (calculoTxt) { try { calculo_preliminar = JSON.parse(calculoTxt); } catch (e) { calculo_preliminar = { texto_livre: calculoTxt }; } } const cliente = await dbGet('clientes', clienteId); if (!cliente) { toast('Cliente não encontrado', 'danger'); return; } const advogados = { george: { nome: 'George Henrique Brito Lacerda', oab: 'OAB/SP 409.102 e OAB/PE 58.906' }, alessandro: { nome: 'Alessandro José de Freitas', oab: 'OAB/SP 374.693' }, }; const adv = advogados[advogadoKey]; const dataProto = new Date().toLocaleDateString('pt-BR', { day: '2-digit', month: 'long', year: 'numeric' }); // Cria entrevista no DB primeiro (status=rascunho) pra ter id const insertResp = await sb.from('inicial_entrevistas').insert([{ cliente_id: clienteId, tipo_acao, status: 'rascunho', entrevista_texto: entrevista, calculo_preliminar, }]).select('id').single(); if (insertResp.error) { toast('Erro ao salvar entrevista: ' + insertResp.error.message, 'danger'); return; } const entrevistaId = insertResp.data.id; const btn = document.getElementById('ini_btn_gerar'); btn.disabled = true; btn.textContent = 'Gerando... (1-2 min)'; const status = document.getElementById('ini_resultado'); status.style.display = 'block'; status.innerHTML = '
🤖 IA pensando... pode demorar 1-2 minutos. Não feche esta janela.
'; try { const dropFields = ['id','clienteId','cliente_id','processoId','processo_id','grupoId','grupo_id','createdAt','updatedAt','created_at','updated_at']; const clienteLimpo = {}; for (const [k, v] of Object.entries(cliente)) { if (dropFields.includes(k) || v == null || v === '') continue; if (typeof v === 'object') continue; clienteLimpo[k] = v; } const payload = { entrevista_id: entrevistaId, cliente: clienteLimpo, tipo_acao, entrevista, calculo_preliminar, oab_signatario: adv.oab, advogado_signatario: adv.nome, comarca, data_protocolo: dataProto, }; const { data, error } = await sb.functions.invoke('gerar-inicial', { body: payload }); if (error) { let msg = error.message || 'Falha'; try { const j = await error.context.json(); if (j && j.error) msg = j.error; } catch(_){} throw new Error(msg); } if (data && data.error) throw new Error(data.error); // Pré-análise de suficiência: se a IA disse que faltam dados, NÃO geramos peça — // mostramos as perguntas pendentes pro advogado completar e tentar de novo. if (data && data.pronto === false) { _iniMostrarPerguntas(data, entrevistaId); toast(`⚠ Faltam dados — veja perguntas (custo desta análise: $${data.custo_usd})`, 'warning'); return; } _iniEntrevistaAtual = { id: entrevistaId, ...data, cliente, tipo_acao }; _iniMostrarResultado(data); toast(`✓ Inicial gerada — custo $${data.custo_usd}`, 'success'); } catch (e) { console.error(e); status.innerHTML = `
`; toast('Erro ao gerar: ' + (e.message || e), 'danger'); } finally { btn.disabled = false; btn.textContent = '🤖 Elaborar inicial com IA'; } } // Quando a IA diz que faltam dados (suficiência=false), mostra as perguntas // pendentes pro advogado completar a entrevista antes de regenerar a peça. function _iniMostrarPerguntas(data, entrevistaId) { const perguntas = data.perguntas_faltantes || []; document.getElementById('ini_resultado').innerHTML = `
${escape(data.motivo || 'A IA precisa de mais informações antes de redigir a peça.')}
Complete a entrevista lá em cima com essas informações e clique de novo em "🤖 Elaborar inicial com IA". Esta análise prévia custou apenas $${data.custo_usd || '0.00'} (não gerou peça).
`; document.getElementById('ini_resultado').style.display = 'block'; document.getElementById('ini_resultado').scrollIntoView({ behavior: 'smooth', block: 'start' }); } function _iniMostrarResultado(data) { const v = data.viabilidade || {}; const docs = data.documentos_necessarios || []; const corViab = v.tem_direito === true ? 'var(--success)' : (v.tem_direito === 'parcial' ? 'var(--warning)' : 'var(--danger)'); const labelViab = v.tem_direito === true ? '✓ Tem direito' : (v.tem_direito === 'parcial' ? '⚠ Direito parcial' : '✗ Direito improvável'); document.getElementById('ini_resultado').innerHTML = `
| Documento | Descrição | Obrigatório |
|---|---|---|
| ${escape(d.nome)} | ${escape(d.descricao || '')} | ${d.obrigatorio ? '✓' : 'opcional'} |
| Nenhum documento listado | ||
`; } function _iniIframeRoot() { // FIX (Fase 5b): retorna documentElement (com
+