Software de gestão jurídica completo com Inteligência Artificial integrada. Captura publicações DJEN, analisa decisões, calcula prazos, agenda audiências, peticiona em lote, gera contratos. Para o advogado que quer focar em advogar — e deixar a máquina cuidar da burocracia.
Já é cliente? Baixe o Agente Desktop (automatiza protocolo e distribuição no PJe):
Baixar .exe
·
.zip
Tudo o que o escritório moderno precisa
Funcionalidades integradas que substituem 10+ ferramentas separadas. Tudo em um único painel, com IA cuidando do trabalho repetitivo.
Captura DJEN automática
Puxa todas as suas publicações diariamente do Diário de Justiça Eletrônico Nacional. Sem você abrir nada.
IA Jurídica integrada
Lê cada publicação, identifica prazo, classifica decisão fav/desf, detecta audiências, sugere requisitórios e cria eventos automaticamente na agenda.
Agenda + Prazos inteligentes
Eventos criados pela IA, lembretes 7 dias antes de audiências, alertas de vencimento, integração com Google Meet/Zoom.
Jurisprudência + Jurimetria
Busca em todos tribunais BR via DataJud/CNJ. Análise estatística por turma, % de êxito por relator, IA Claude pra cada acórdão.
Peticionamento automatizado
Lote de processos com a mesma peça? Os autopets do IURIA protocolam tudo no eSAJ, eproc, PJe — TJSP, TJSC, TRFs, TRTs.
Petição inicial com IA
Descreve o caso em 3 linhas, anexa documentos do cliente, e a IA monta a peça completa com fundamentação jurídica formal.
Financeiro completo
Honorários, despesas fixas, previsão de receita, gráficos por cliente/grupo, lucratividade real do escritório.
Kanban de prospecção
Pipeline de leads com checklist de documentos, automações WhatsApp, integração webhook (Meta Ads/Zapier).
Assinatura digital (Autentique)
Procurações e contratos assinados pelo cliente no celular em segundos, com validade jurídica via MP 2.200-2/2001.
Tribunais integrados
Peticionamento, distribuição de iniciais e download de autos — validados em operação real, tribunal a tribunal. Lista atualizada continuamente.
✓ Validados em produção
TJSP · eSAJ 1º grau — protocolo, distribuição e autosTJSP · eproc 1º grau — protocolo e distribuiçãoTJPE · PJe — protocolo e autosJustiça Federal · TRF1, TRF3 e TRF5 (PJe) — protocolo e autosTRT da 2ª Região · PJe — protocolo
Consulta de publicações (DJEN), jurisprudência (DataJud/CNJ) e demais funcionalidades cobrem todos os tribunais do país.
Planos sob medida pra cada escritório
A conta é por advogado captando — não por login. Estagiário, secretária e equipe de apoio entram sem custo adicional. Sem fidelidade, cancele quando precisar. Todos com 15 dias de teste grátis.
O que é um “advogado captando”
É cada inscrição na OAB que você cadastra no IURIA para que o sistema puxe automaticamente as publicações daquele advogado no DJEN — em todos os tribunais do país. Uma inscrição = um advogado captando. Quem só consulta, despacha, assina ou acompanha os prazos não conta: cadastre quantos usuários quiser no escritório, em qualquer plano.
Essencial
Pra o advogado que toca a banca sozinho
R$97/mês
1 advogado captando (1 inscrição na OAB no DJEN)
Usuários do escritório sem custo por login
IURIA Autenticador incluído — login automático nos tribunais
Captura DJEN automática
Cadastro de clientes ilimitado
Agenda + prazos
Financeiro básico
Modelos de procuração e contratos
Assistente no Telegram
Gestor de arquivos
IA Jurídica avançada
Peticionamento automatizado
Jurisprudência Premium
MAIS POPULAR
Profissional
Pra a banca com sócios e equipe
R$197/mês
Até 5 advogados captando (5 inscrições na OAB)
Tudo do plano Essencial
IURIA Autenticador incluído — para todos os advogados
Assistente no Telegram — prazos, publicações e autos no celular
Gestor de arquivos — documentos do escritório na nuvem
IA Jurídica completa (análise de publicações + audiências)
Jurimetria estatística por turma/relator
Kanban de prospecção + automações WhatsApp
Autentique integrado (assinatura digital)
Petição inicial assistida por IA
Peticionamento automatizado em lote
Premium
Escritório de alta performance — automação total
R$397/mês
Até 20 advogados captando (20 inscrições na OAB)
Tudo do plano Profissional
IURIA Autenticador incluído — para todos os advogados
Assistente no Telegram e Gestor de arquivos
Autopets em lote (eSAJ, eproc, PJe, TRT, TRF)
Requisitório DEPRE automatizado
Jurisprudência Premium (ementa textual + IA)
Análise IA ilimitada de processos e acórdãos
Triagem automática de novos casos
Suporte prioritário (WhatsApp dedicado)
Onboarding com nosso especialista
Mais de 20 advogados captando?
Departamentos jurídicos e bancas grandes têm proposta sob medida, com tudo do Premium, onboarding dedicado e condição própria de volume.
IURIA Autenticador incluído em todos os planos. O código de 6 dígitos dos tribunais é gerado e preenchido pelo computador — vendido à parte por R$ 39,90/mês, vem sem custo pra quem assina o IURIA.
Pagamento mensal recorrente · 15 dias grátis · Sem fidelidade · 🇧🇷 NF-e brasileira
Pronto pra modernizar seu escritório?
Escolha um plano e comece a usar em 5 minutos. Sem instalação, sem configuração complicada.
IURIA
Acesso restrito ao escritório
IURIA
Comece grátis em 30 segundos
15 dias grátis · sem cartão de crédito · cancele quando quiser
envolvente.
const tagAbreScript = '<' + 'script>';
const tagFechaScript = '<' + '/script>';
const autoPrint = tagAbreScript +
"window.addEventListener('load', function(){ setTimeout(function(){ window.print(); }, 400); });" +
tagFechaScript;
// Nome sugerido do PDF: " - ". O tipo sai do comando
// (mesma detecção usada no nome do anexo); o cliente vem da parte do polo.
const comandoAtual = (document.getElementById('ia_cmd')?.value || '').trim();
const tipoPeca = _iaDetectTipoPeticao(comandoAtual);
const titulo = _tituloImpressao(tipoPeca, _iaNomeParteCliente());
let htmlComPrint = _injetarTituloHtml(_iaPeticaoHtml, titulo);
// NORMALIZA A MARGEM (fix 23/07): a peça vem com @page{margin:25mm} E
// body{padding:25mm} — na impressão isso DOBRA (5cm de cada lado, "de receita").
// Injeto por último uma folha que zera o padding do body e fixa a margem só no
// @page: 3cm esquerda / 2cm direita / 2,5cm topo / 2cm base (padrão forense).
const _fixMargem = '';
htmlComPrint = /<\/head>/i.test(htmlComPrint)
? htmlComPrint.replace(/<\/head>/i, _fixMargem + '')
: htmlComPrint.replace(/]*>/i, m => m + _fixMargem);
htmlComPrint = htmlComPrint.replace(/<\/body>/i, autoPrint + '');
w.document.open();
w.document.write(htmlComPrint);
w.document.close();
try { var _ttl = w.document.querySelector('title'); if (_ttl && _ttl.textContent) w.document.title = _ttl.textContent; } catch(_e){}
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; }
// .docx OOXML nativo (abre em Word, Google Docs e celular — sem altChunk).
const blob = _docxBlobDaPeca(_iaPeticaoHtml);
if (!blob || blob.size < 400) throw new Error('documento vazio');
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
_baixarBlobComo(blob, `peticao-ia-${stamp}.docx`);
toast('DOCX baixado. Abra no Word, Google Docs ou no celular.', '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.
============================== */
// Extrai o nº do incidente do texto da publicação DJEN do TJSP.
// Padrão: "Processo / - " → captura .
// Ex: "Processo 1046979-72.2022.8.26.0053/786 - Precatório" → "786"
// Sem incidente (processo principal): "Processo - Cumprimento..." → null
function extrairNumeroIncidente(texto) {
if (!texto) return null;
const m = String(texto).match(/Processo\s+\d{7}-\d{2}\.\d{4}\.\d\.\d{2}\.\d{4}\s*\/\s*(\d{1,6})\b/i);
return m ? m[1] : null;
}
// Detecta ONDE a peça deve ser protocolada (instância/grau + tribunal) a partir
// da publicação de origem: órgão julgador (Vara→1g; Câmara/Desembargador→2g),
// sigla do tribunal (STF/STJ/TST/TSE→superior) e classe. Sem publicação, usa o
// NPU. É o que garante protocolar no 2º grau / tribunal superior corretos.
function _detectarLocalTramitacao(pub, numMask, processo) {
const _n = (s) => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
const orgao = _n(pub && pub.orgao);
const classe = _n(pub && pub.nome_classe);
const texto = _n(pub && pub.texto).slice(0, 1500);
const sigla = String(pub && pub.tribunal || '').toUpperCase();
const ctx = orgao + ' ' + classe + ' ' + texto;
const dig = String(numMask || '').replace(/\D/g, '');
const segJ = dig.length === 20 ? dig.charAt(13) : ''; // J do NPU: 4=federal, 5=trabalho, 8=estadual, 6=eleitoral
// 1) Tribunais superiores (pela sigla OU pelo nome no órgão/classe)
if (/\bSTF\b/.test(sigla) || /supremo tribunal federal/.test(ctx)) return { tribunal: 'STF', grau: 'superior', instancia: 'superior', sistema: null };
if (/\bSTJ\b/.test(sigla) || /superior tribunal de justica/.test(ctx)) return { tribunal: 'STJ', grau: 'superior', instancia: 'superior', sistema: null };
if (/\bTST\b/.test(sigla) || /tribunal superior do trabalho/.test(ctx)) return { tribunal: 'TST', grau: 'superior', instancia: 'superior', sistema: null };
if (/\bTSE\b/.test(sigla) || /tribunal superior eleitoral/.test(ctx)) return { tribunal: 'TSE', grau: 'superior', instancia: 'superior', sistema: null };
// 2) Tribunal de origem: NPU (confiável p/ TJSP/TJSC) → sigla da publicação → processo
const isTjsp = /^\d{7}-\d{2}\.\d{4}\.8\.26\.\d{4}$/.test(numMask) || sigla === 'TJSP';
const isTjsc = /^\d{7}-\d{2}\.\d{4}\.8\.24\.\d{4}$/.test(numMask) || sigla === 'TJSC';
const tribunal = isTjsp ? 'TJSP' : (isTjsc ? 'TJSC' : (sigla || (processo && processo.tribunal) || null));
const isFederal = segJ === '4' || /^TRF\d/.test(String(tribunal || '').toUpperCase());
// 3) INSTÂNCIA pelo órgão/classe (fonte = onde o processo está AGORA):
// - turma_recursal = Juizado Especial (recurso inominado / Turma ou Colégio Recursal)
// - segundo = Justiça Comum 2º grau (Câmara/Desembargador/Apelação/TRF tribunal)
// - primeiro = 1ª instância
const ehRecursal = /turma recursal|colegio recursal|recurso inominado|juizado especial|\btr\/[a-z]{2}\b|\d+a?\s+turma recursal/.test(ctx);
const ehSegundo = /\bcamara\b|camara civel|camara criminal|desembargad|\bapelacao\b|agravo de instrumento|remessa necessaria|tribunal de justica|tribunal regional federal|grupo de camaras|orgao especial|vice[\- ]?presidenc|gabinete\s+des|turma(?!\s+recursal)/.test(ctx);
let instancia = ehRecursal ? 'turma_recursal' : (ehSegundo ? 'segundo' : 'primeiro');
// 4) GRAU p/ os engines (1g→pje1g, 2g→pje2g). URLs confirmadas pelo George (TRF5):
// - 1º grau = pje1g.trf5/.../peticaoavulsa.seam
// - Turma Recursal = pje2g.trf5/.../peticaoavulsa.seam (2ª instância dos JUIZADOS)
// - 2º grau comum = pje2g.trf5 também (TRF, desembargadores, Justiça Comum)
// Então RECURSO (Turma Recursal de Juizado OU 2º grau comum) → grau '2g' (pje2g).
let grau;
if (instancia === 'turma_recursal') grau = '2g';
else if (instancia === 'segundo') grau = '2g';
else grau = '1g';
const sistema = isTjsp ? 'esaj' : (isTjsc ? 'eproc-tjsc' : null);
// grauComSinal = a instância veio de um sinal REAL do órgão/classe (não do default
// silencioso 'primeiro'). 1º grau só conta como sinal se o órgão mencionar Vara/Juízo
// — assim o back-fill (Parte C) não grava '1g' só porque a publicação não disse nada.
const ehPrimeiroSinal = /\bvara\b|\bjuizo\b|\bforo\b|juizado especial|\d+a?\s+vara/.test(ctx);
const grauComSinal = ehRecursal || ehSegundo || ehPrimeiroSinal;
return { tribunal, grau, instancia, sistema, grauComSinal };
}
/* Parte C: back-fill de sistema/grau no PROCESSO a partir de uma publicação já
analisada. REGRA: o que o advogado definiu no cadastro PREVALECE SEMPRE — a
publicação só PREENCHE quando o campo está VAZIO **e** o advogado não marcou
aquele campo como manual (sistema_manual/grau_manual = false).
- sistema: só grava se o cadastro está vazio, não é manual e a detecção tem sinal
claro (TJSC→eproc-tjsc, TJSP→esaj, PJe pelos demais). Pra TJSP, eSAJ×eproc não dá
pra distinguir pela publicação → só preenche 'esaj' se estava vazio.
- grau: grava só quando a publicação trouxe sinal real (det.grauComSinal), o
cadastro está vazio e não é manual. NUNCA sobrescreve grau manual nem grau já
preenchido — mesmo que a publicação divirja (só loga, não toca).
Retorna o patch aplicado (ou null) só pra log/debug. */
async function _backfillProcessoSistemaGrau(processoId, det) {
if (!processoId || !det) return null;
try {
const { data: proc } = await sb.from('processos').select('sistema,grau,sistema_manual,grau_manual').eq('id', processoId).maybeSingle();
if (!proc) return null;
const patch = {};
const sistemaAtual = _normSistemaProc(proc.sistema);
const grauAtual = _normGrauProc(proc.grau);
// MANUAL PREVALECE: o que o advogado definiu no cadastro nunca é sobrescrito.
// Regra única (sistema e grau): só grava quando o campo está VAZIO **e** o flag
// manual é false. Se o advogado definiu (flag true), NUNCA toca — mesmo que a
// publicação "divirja" (o processo pode ter sido remetido e o advogado já sabe).
const sistemaDet = _normSistemaProc(det.sistema);
if (sistemaDet && !sistemaAtual && !proc.sistema_manual) patch.sistema = sistemaDet;
// grau: só quando há sinal real na publicação, o cadastro está vazio e não é manual.
if (det.grauComSinal) {
const grauDet = _normGrauProc(det.instancia === 'turma_recursal' ? 'turma_recursal' : det.grau);
if (grauDet && !grauAtual && !proc.grau_manual) patch.grau = grauDet;
else if (grauDet && proc.grau_manual && grauDet !== grauAtual) {
console.log('[backfill] grau manual (' + grauAtual + ') prevalece sobre detectado (' + grauDet + ') no processo ' + processoId);
}
}
if (!Object.keys(patch).length) return null;
patch.updated_at = new Date().toISOString();
const { error } = await sb.from('processos').update(patch).eq('id', processoId);
if (error) { console.warn('_backfillProcessoSistemaGrau:', error.message); return null; }
return patch;
} catch (e) { console.warn('_backfillProcessoSistemaGrau:', e); return null; }
}
/* ── BACK-LINK publicação → processo_id ──────────────────────────────────────
PROBLEMA: o batch analisar-publicacoes.js só analisa publicações COM processo_id
(prazos/perícias/audiências). Publicações capturadas ANTES do processo ser
cadastrado ficam com processo_id NULL e nunca entram na análise — mesmo casando
o número, a tela só "mostra" o vínculo visualmente, mas a coluna fica NULL.
FIX: ao cadastrar/salvar um processo (ou vincular uma publicação), varre todas
as publicacoes do escritório (RLS já isola o tenant) cujo NPU normalizado (20
dígitos, sem máscara) bate com o número do processo E que estão com processo_id
NULL, e seta processo_id = . Idempotente (só toca quem está NULL).
Roda fire-and-forget; toast discreto. Retorna a quantidade vinculada (p/ log). */
async function _backlinkPublicacoesOrfas(processoId, numeroProc) {
try {
const dig = String(numeroProc || '').replace(/\D/g, '');
if (!processoId || dig.length !== 20) return 0;
// RLS (tenant_publicacoes_*) restringe ao escritório do usuário logado — não há
// como casar publicação de outro escritório. Trazemos só órfãs (processo_id NULL)
// e casamos o NPU em JS (a coluna pode estar com ou sem máscara, com sufixo de
// incidente "/786" etc. — comparar por 20 dígitos é o critério estável).
const { data: orfas, error } = await sb.from('publicacoes')
.select('id, numero_processo, numero_processo_mascara')
.is('processo_id', null);
if (error) { console.warn('_backlinkPublicacoesOrfas select:', error.message); return 0; }
const ids = (orfas || []).filter(pub => {
const a = String(pub.numero_processo || '').replace(/\D/g, '');
const b = String(pub.numero_processo_mascara || '').replace(/\D/g, '');
// Casa quando os 20 primeiros dígitos do NPU batem (ignora sufixo de incidente).
return a.slice(0, 20) === dig || b.slice(0, 20) === dig;
}).map(pub => pub.id);
if (!ids.length) return 0;
const { error: errUp } = await sb.from('publicacoes')
.update({ processo_id: processoId }).in('id', ids).is('processo_id', null);
if (errUp) { console.warn('_backlinkPublicacoesOrfas update:', errUp.message); return 0; }
// Atualiza o cache em memória pra a tela refletir sem reload.
try {
(_pubsCache || []).forEach(p => { if (ids.includes(p.id)) p.processoId = processoId; });
} catch (e) { /* cache opcional */ }
return ids.length;
} catch (e) { console.warn('_backlinkPublicacoesOrfas:', e); return 0; }
}
// ===== PRÉ-CHECAGEM de sistema+grau antes de enviar p/ protocolo =====
// Pedido do George: "na hora de enviar p/ protocolo, já uma checagem se está tudo
// certo, o sistema e o grau pra peticionar no lugar correto, ou pede pro advogado
// atualizar na pasta do processo." Heurística pelo NPU (J·TR + foro/OOOO) cruzada
// com o cadastro (sistema/grau). Coerente → segue sem atrito. Faltando/incoerente
// → para e oferece abrir o cadastro pra corrigir (ou "Enviar assim mesmo").
//
// Retorna: true = pode enviar; false = abortar (faltou definir, ou o advogado foi
// corrigir o cadastro, ou cancelou). NÃO protocola nada — só decide o roteamento.
async function _iaPreCheckSistemaGrau(p) {
if (!p) return true;
const npuDig = String(p.numero || '').replace(/\D/g, '');
const grau = _normGrauProc(p.grau); // '' quando vazio
const jtr = (npuDig.length === 20) ? (npuDig.charAt(13) + '.' + npuDig.substring(14, 16)) : '';
const foro = (npuDig.length === 20) ? npuDig.substring(16, 20) : ''; // OOOO
const ehTjsp = jtr === '8.26';
// Atalho pra abrir o cadastro do processo (corrige sistema/grau). Garante o
// contexto do cliente pra o save funcionar mesmo fora da ficha do cliente.
const abrirCadastro = () => {
try { if (p.clienteId) _currentClienteId = p.clienteId; } catch (e) {}
try { formProcessoNoCliente(p.id); } catch (e) { console.error('abrir cadastro:', e); }
};
// FILOSOFIA: este pre-check NÃO trava o envio nos casos roteáveis. Sistema/grau
// VAZIOS são NORMAIS — o protocolo é roteado pelo NPU + publicação + cadastro, e o
// grau é inferido (foro 0000 = 2º grau, "Em grau de recurso", etc.). (Regressão
// jun/2026: o pre-check antigo BLOQUEAVA todo processo sem sistema/grau preenchido
// via confirm() — quando o advogado cancelava/o confirm era suprimido, "abria e
// fechava a aba sem enviar". Caso George 23/06.) ÚNICO bloqueio duro: o cenário
// genuinamente IMPOSSÍVEL de rotear (TJSP marcado como PJe). O resto: só aviso.
if (ehTjsp && (p.sistema || '').toLowerCase().includes('pje')) {
const abrir = confirm('O cadastro está com sistema PJe, mas o TJSP não usa PJe — só eSAJ ou eproc. Corrija o sistema no cadastro do processo.\n\nAbrir o cadastro agora? (Cancelar não envia.)');
if (abrir) abrirCadastro();
return false; // não dá pra rotear PJe no TJSP — único caso que trava
}
// AVISOS não-bloqueantes (só informam; o envio SEGUE normalmente):
if (ehTjsp && foro === '0000' && grau && grau !== '2g') {
// O watcher trata foro 0000 como 2º grau de qualquer forma — só avisa.
toast('Número do 2º grau do TJSP (Tribunal — foro 0000): vou enviar como 2º grau. Se quiser travar, ajuste o grau no cadastro do processo.', 'warning');
} else if (ehTjsp && foro === '0500' && grau === '2g') {
toast('DEPRE/precatório (foro 0500) costuma tramitar em 1º grau, mas o cadastro está como 2º grau. Enviando assim mesmo — confira se for o caso.', 'warning');
}
return true; // segue — não trava o envio
}
async function ia_enviarProtocolo(preWin) {
const processoId = _iaPeticaoProcId;
const btn = document.getElementById('ia_btnProtocolo');
if (!_iaPeticaoHtml) { toast('Gere a petição primeiro.', 'danger'); return; }
// Abre o Protocolo numa ABA NOVA já aqui — DENTRO do gesto do clique, então o
// navegador não bloqueia o pop-up. A petição segue aberta nesta aba. Se veio do
// "Gerar e enviar", reaproveita a aba já aberta lá (preWin) — o gesto era de lá.
let _protoWin = (preWin && !preWin.closed) ? preWin : null;
if (!_protoWin) { try { _protoWin = window.open(location.pathname + '?view=protocolo', '_blank'); } catch (e) {} }
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;
// BLOQUEIO: não enfileira protocolo com número incompleto/inválido (CNJ = 20 díg.
// + DV). Evita preparar a peça no processo ERRADO (ex.: dígito faltando — caso Dayse).
if (!validarNumeroCNJ(p?.numero)) {
toast('Não enviei para protocolo: o número do processo está incompleto ou inválido (' + (p?.numero || 'sem número') + '). Corrija o número CNJ (20 dígitos) no cadastro do processo e tente de novo.', 'error');
if (btn) { btn.disabled = false; btn.textContent = '📤 Enviar p/ protocolo'; }
if (_protoWin && !_protoWin.closed) { try { _protoWin.close(); } catch (e) {} }
return;
}
// PRÉ-CHECAGEM sistema+grau: confere (pelo NPU x cadastro) se o protocolo vai
// pro lugar certo. Sistema/grau vazios ou incoerentes → para e oferece abrir o
// cadastro pra ajustar (ou enviar assim mesmo). Coerente → segue sem atrito.
if (!(await _iaPreCheckSistemaGrau(p))) {
if (btn) { btn.disabled = false; btn.textContent = '📤 Enviar p/ protocolo'; }
if (_protoWin && !_protoWin.closed) { try { _protoWin.close(); } catch (e) {} }
return;
}
// TERCEIRO INTERESSADO: o eSAJ exige cadastrar o cliente como SOLICITANTE com
// qualificação completa. Se faltar nascimento/endereço-com-número, avisa BEM
// visível (confirm bloqueante) ANTES de enfileirar — o advogado pode prosseguir,
// mas sabendo que o protocolo automático vai parar na inclusão do solicitante.
if (!_iaConfirmarTerceiroIncompleto(_iaChecarTerceiroCadastro(_iaPoloConfirmado, cliente))) {
if (btn) { btn.disabled = false; btn.textContent = '📤 Enviar p/ protocolo'; }
if (_protoWin && !_protoWin.closed) { try { _protoWin.close(); } catch (e) {} }
return;
}
const pubId = document.getElementById('ia_pub')?.value || null;
const comando = (document.getElementById('ia_cmd')?.value || '').trim();
// INCIDENTE: se a peça veio de uma publicação de incidente (ex: precatório
// "Processo /786"), o protocolo TEM que ser no incidente, não no
// processo-mãe. Extrai o nº do incidente do texto da publicação de origem.
let numeroIncidente = null;
let pubRow = null;
if (pubId) {
try {
const { data: pub } = await sb.from('publicacoes').select('texto, orgao, nome_classe, tribunal').eq('id', pubId).single();
pubRow = pub;
numeroIncidente = extrairNumeroIncidente(pub?.texto || '');
} catch (e) { /* sem incidente detectável */ }
}
// 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}`;
// Nome amigável do documento (o que o tribunal vê): "Tipo da peça - Cliente".
// O watcher_pdf deriva o nome do PDF do arquivo_nome (troca .html→.pdf).
const tipoPet = _iaDetectTipoPeticao(comando);
const _safeFn = (s) => (s || '').replace(/[\\/:*?"<>|]+/g, ' ').replace(/\s+/g, ' ').trim();
const nomeAmigavel = (`${_safeFn(tipoPet)} - ${_safeFn(cliente?.nome || 'peticao')}`).slice(0, 120) + '.html';
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 || '';
// Detecta o LOCAL DE TRAMITAÇÃO (grau/instância + tribunal) pela publicação
// de origem (órgão julgador + classe + sigla). Cai pro NPU se não houver pub.
const local = _detectarLocalTramitacao(pubRow, numMask, p);
const tribunalDetectado = local.tribunal;
const sistemaDetectado = local.sistema;
// FALLBACK por TIPO da peça SÓ quando NÃO há publicação de origem (a publicação
// é a fonte mais confiável da instância atual — ela diz se o processo está no
// Juizado/Turma Recursal ou na Justiça Comum 2º grau). Sem pub, usa o tipo.
if (!pubRow && local.instancia === 'primeiro') {
const tt = ((tipoPet || '') + ' ' + (comando || '')).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
const isFed = /^TRF\d/.test(String(local.tribunal || '').toUpperCase()) || numMask.replace(/\D/g, '').charAt(13) === '4';
if (/recurso inominado|turma recursal|colegio recursal|juizado especial/.test(tt)) {
local.instancia = 'turma_recursal'; local.grau = isFed ? '1g' : '2g'; // JEF federal: Turma Recursal fica no pje1g
} else if (/apela|agravo de instrumento|recurso ordinario|embargos infringentes/.test(tt)) {
local.instancia = 'segundo'; local.grau = '2g';
}
}
// FONTE DE VERDADE = CADASTRO MANUAL. O sistema (plataforma) e o grau que o
// advogado TRAVOU no cadastro (sistema_manual/grau_manual=true) são VERDADE
// ABSOLUTA do roteamento — prevalecem SEMPRE sobre a detecção da publicação/NPU,
// INCLUSIVE quando há publicação de origem. Corrige o caso Aguinaldo: cadastro
// eproc/1g (manual) mas a publicação fazia o item ir pro eproc 2g (Turma
// Recursal) e pedir login no perfil errado. Sem flag manual, cai na detecção.
const sistemaCadastro = _normSistemaProc(p?.sistema);
const grauCadastro = _normGrauProc(p?.grau);
// Flags manuais vêm como snake_case do banco (PROCESSOS_FIELD_MAP não as mapeia,
// fromDb passa direto); leitura tolerante às variantes camelCase por segurança.
const sistemaManual = !!(p?.sistema_manual ?? p?.sistemaManual);
const grauManual = !!(p?.grau_manual ?? p?.grauManual);
// Sistema: manual prevalece sempre; senão usa cadastro-se-houver, senão detecção.
const sistemaFinal = (sistemaManual && sistemaCadastro) ? sistemaCadastro
: (sistemaCadastro || sistemaDetectado);
// Grau: manual prevalece SEMPRE (mesmo com publicação). Mapeamento cadastro→fila:
// 1g → grau '1g', instancia null; 2g → grau '2g', instancia null (não 'segundo');
// turma_recursal → grau '2g', instancia 'turma_recursal'.
if (grauManual && grauCadastro) {
const m = _mapGrauCadastroParaFila(grauCadastro);
local.grau = m.grau;
local.instancia = m.instancia;
} else if (grauCadastro && !pubRow) {
// Cadastro não-manual: comportamento anterior — só prevalece SEM publicação.
local.grau = grauCadastro;
local.instancia = grauCadastro === 'turma_recursal' ? 'turma_recursal'
: (grauCadastro === '2g' ? 'segundo' : 'primeiro');
}
const anexosJuntar = (_iaPeticaoAnexos || []).filter(x => x.uso === 'juntar');
const anexosNota = anexosJuntar.length ? (' | Juntar c/ a petição: ' + anexosJuntar.map(a => a.file.name).join(', ')) : '';
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, ''),
numero_incidente: numeroIncidente, // ← protocolar NO incidente, não no processo-mãe
grau: local.grau, // ← cadastro (sem pub) > publicação > NPU
instancia: local.instancia, // ← primeiro|turma_recursal|segundo|superior (Juizado vs Justiça Comum)
nome_parte: cliente?.nome || null,
tribunal: tribunalDetectado,
sistema: sistemaFinal, // ← cadastro (FONTE DE VERDADE) > publicação/NPU
polo: (_iaPoloConfirmado || '').toLowerCase() || null, // ← polo confirmado pelo advogado: fonte autoritativa do gate "Adicionar solicitante" (eSAJ terceiro)
arquivo_path: storagePath, // ← HTML; o watcher converte pra PDF e atualiza este campo
arquivo_nome: nomeAmigavel, // ← "Tipo - Cliente.html" → watcher_pdf vira ".pdf"
tipo_peticao: tipoPet,
status: 'aguardando_pdf', // ← watcher_pdf renderiza PDF e marca 'aprovado' direto (v2: pula aguardando_revisao); watcher_protocolo entao roda o autopet do tribunal
observacao: (((comando ? 'Comando IA: ' + comando : '') + anexosNota).trim()).slice(0, 500) || null,
}]);
if (errIns) { await sb.storage.from(ANEXOS_BUCKET).remove([storagePath]); throw new Error(errIns.message); }
// Parte C: back-fill do processo com o sistema/grau detectado pela publicação
// (só quando vazio/com sinal — cadastro manual prevalece). Fire-and-forget pra
// não atrasar o fluxo. Da próxima vez o cadastro já é a fonte de verdade.
_backfillProcessoSistemaGrau(processoId, local).catch(() => {});
// A aba do Protocolo foi aberta ANTES do insert (pra não ser bloqueada como
// pop-up) — então ela carregou e fez o fetch SEM esta linha. Agora que a linha
// existe, recarrega a aba pra ela já listar este item (corrige "não aparece
// no protocolo a petição que acabei de enviar"). O Realtime sozinho não
// garante: o canal pode não ter terminado de assinar antes do insert.
try { if (_protoWin && !_protoWin.closed) _protoWin.location.reload(); } catch (e) {}
// Anexos "juntar com a petição": sobem pros Anexos do cliente (sinalizados) e
// ficam prontos pra você juntar no momento do protocolo/assinatura (A3).
let anexosOk = 0;
for (const a of anexosJuntar) {
try {
const safe = a.file.name.replace(/[^\w.\-]+/g, '_');
const apath = `${processoId}/${Date.now()}-anexo-${safe}`;
const { error: eUp } = await sb.storage.from(ANEXOS_BUCKET).upload(apath, a.file, { cacheControl: '3600', upsert: false, contentType: a.file.type || 'application/octet-stream' });
if (eUp) { console.error('upload anexo:', eUp); continue; }
await dbAdd('documentos', {
processoId,
nome: a.file.name,
descricao: '[Anexo p/ juntar com a petição]' + (a.nota ? ' ' + a.nota : ''),
tipo: 'Anexo da petição',
storagePath: apath,
tamanhoBytes: a.file.size,
mimeType: a.file.type || null,
criadoEm: new Date().toISOString(),
atualizadoEm: new Date().toISOString(),
});
anexosOk++;
} catch (eA) { console.error('anexo juntar:', eA); }
}
if (anexosJuntar.length) toast(`${anexosOk}/${anexosJuntar.length} anexo(s) salvos nos Anexos do cliente p/ juntar com a petição.`, anexosOk === anexosJuntar.length ? 'success' : 'warning');
toast('Enviada! Esta peça segue aberta — abra o Protocolo na nova aba ou pelo menu quando quiser.', 'success');
// NÃO fecha o modal da petição (o advogado pode revisar/regerar/anexar). Em vez
// de trocar de view aqui, oferece um link que abre o Protocolo em NOVA aba do
// navegador (clique = gesto do usuário, não é bloqueado por pop-up).
const _st = document.getElementById('ia_status');
if (_st) _st.innerHTML = '✓ Enviada para protocolo. '
+ (_protoWin
? 'Abri o Protocolo em outra aba — acompanhe lá (fica VERDE quando estiver pronta pra revisar e assinar). Esta peça segue aberta aqui.'
: 'Abra o Protocolo: nova aba ↗ (o navegador bloqueou a abertura automática).');
if (btn) { btn.dataset.sent = '1'; btn.disabled = true; btn.textContent = '✓ Enviada'; } // evita reenvio duplicado
} catch (e) {
console.error(e);
toast('Erro ao enviar p/ protocolo: ' + (e && e.message ? e.message : e), 'danger');
} finally {
if (btn && !btn.dataset.sent) { 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')
.eq('processo_id', procId)
.maybeSingle();
const msgConfirma = analise
? `Já existe análise IA desse processo (atualizada em ${(analise.atualizado_em || '').slice(0,10)}). Deseja refazer a análise da íntegra? Continuar?`
: `Vai analisar a íntegra com IA. 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');
closeModal();
document.querySelector('.nav-btn[data-view="protocolo"]')?.click();
} 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'],
aguardando_pdf: ['warning', '⏳ gerando o PDF (~15s) → depois abre o tribunal'], // watcher_pdf renderiza
aguardando_protocolo: ['warning', '⏳ na fila do watcher'],
aprovado: ['warning', '⏳ preparando no tribunal (watcher)'],
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: ['success', '✅ PRONTO — revisar e assinar (PIN A3)'],
};
let _protoRealtimeCh = null;
let _protoPollHandle = null;
let _protoLoginAlertados = new Set(); // itens cujo popup "falta login" já mostrei nesta sessão
// Re-fetch da fila + render (usado pelo Realtime e pelo polling de segurança).
async function _protoRefetch() {
const tb = document.getElementById('tbProto');
if (!tb) return false;
const { data } = await sb.from('protocolo_fila').select('*').order('created_at', { ascending: false });
_protoRenderTabela(tb, data || []);
// Painel de distribuições (petição inicial) acompanha o mesmo ciclo de refresh.
try { await _distPainelRefetch(); } catch (e) {}
return true;
}
/* ── ACOMPANHAMENTO AO VIVO das DISTRIBUIÇÕES (petição inicial) na página de
Protocolos. Pedido do George: enviou a inicial → cai aqui e acompanha o card
até ficar VERDE ("pronta — revise e assine com o A3"). O motor local NÃO traz
a aba do tribunal pra frente; o advogado abre quando o card ficar verde.
Estados: azul=preparando (passo X/5) · verde=pronta (aguardando_assinatura)
· âmbar=parou numa pendência (lista o que falta) · vermelho=erro. ── */
let _distPainelStatus = new Map(); // id → estado anterior (notifica só na TRANSIÇÃO)
let _distPainelClientes = new Map(); // cliente_id → nome (cache p/ não refazer N+1)
let _distDestaqueId = null; // distribuição recém-enviada (destaque visual)
// Estado consolidado de uma linha de `distribuicoes` (progresso do dirigir_pje).
// Filtro do painel de distribuições (pedido do George 15/07): 3 abas — Pendentes
// (precisa de ação ou em andamento), Concluídas (pronta, robô terminou) e
// Arquivadas. Assim o advogado escolhe o que ver, sem poluição.
let _distFiltro = 'pendentes';
function _distSetFiltro(v) {
_distFiltro = ['pendentes', 'concluidas', 'arquivadas'].includes(v) ? v : 'pendentes';
try { _distPainelRefetch(); } catch (e) {}
}
async function distDesarquivar(id) {
const { error } = await sb.from('distribuicoes').update({ status: 'requer_acao', updated_at: new Date().toISOString() }).eq('id', id);
if (error) { toast('Erro ao desarquivar: ' + error.message, 'danger'); return; }
_distPainelStatus.delete(id);
_distFiltro = 'pendentes';
toast('Distribuição desarquivada — voltou para Pendentes');
try { _distPainelRefetch(); } catch (e) {}
}
function _distEstado(r) {
const prog = r.progresso || {};
const pend = Array.isArray(prog.pendencias) ? prog.pendencias
: (Array.isArray(r.resultado?.pendencias) ? r.resultado.pendencias : []);
// 'concluida' = o ADVOGADO confirmou que assinou (A3) e protocolou — clique no
// "✓ Assinei e protocolei" (pedido do George 23/07). Robô pronto ≠ concluída.
if (r.status === 'concluida') return { chave: 'concluida', pend: [] };
const pronta = r.status === 'aguardando_assinatura' || prog.pronta === true;
if (pronta) return { chave: 'pronta', pend: [] }; // pendências da pronta = instrução NOSIGN, não ação
if (r.status === 'requer_acao') return { chave: 'pendencia', pend }; // terminal dedicado: parou aguardando o advogado
if (r.status === 'erro') return { chave: 'erro', pend };
const parou = !!r.resultado?.pararEm || /parou|requer sua a/i.test(prog.rotulo || '') || pend.length > 0;
if (parou) return { chave: 'pendencia', pend };
return { chave: 'andamento', pend: [] };
}
async function _distPainelRefetch() {
const el = document.getElementById('distPainel');
if (!el) return;
// Mostra o que está VIVO (na fila/rodando) + o desfecho recente (7 dias) — o
// histórico antigo não polui a página. RLS por escritório já filtra o tenant.
const corte = Date.now() - 7 * 24 * 3600 * 1000;
const corteIso = new Date(corte).toISOString();
let rows = [];
try {
// FILTRO NO SERVIDOR (não mais .limit(15) cru): tudo que está VIVO/acionável
// (pronto/distribuindo/requer_acao) vem SEMPRE, independente da janela de linhas, +
// o desfecho recente (updated_at nos últimos 7 dias). Corrige o bug do .limit(15): com
// 16+ distribuições, uma pendência 'requer_acao' antiga era empurrada pra fora do fetch
// e o pin client-side não tinha o que fixar. Limite 50 só evita puxar histórico gigante
// (acionáveis são poucos); o filtro client abaixo é rede de segurança + o destaque.
const cols = 'id,cliente_id,tribunal,sistema,grau,classe_nome,status,numero_processo,progresso,resultado,created_at,updated_at';
if (_distFiltro === 'arquivadas') {
// Aba Arquivadas: puxa direto as arquivadas (não dependem da janela de 7 dias).
const { data } = await sb.from('distribuicoes').select(cols)
.eq('status', 'arquivada').order('updated_at', { ascending: false }).limit(50);
rows = data || [];
} else {
// Pendentes/Concluídas: o que está VIVO/acionável + o desfecho recente (7 dias).
const { data } = await sb.from('distribuicoes').select(cols)
.or(`status.in.(pronto,distribuindo,requer_acao,erro,aguardando_assinatura),updated_at.gte.${corteIso}`)
.order('updated_at', { ascending: false }).limit(50);
rows = (data || []).filter(r => r.status !== 'arquivada' && (
['pronto', 'distribuindo', 'requer_acao', 'erro', 'aguardando_assinatura'].includes(r.status)
|| r.id === _distDestaqueId
|| new Date(r.updated_at || r.created_at).getTime() >= corte));
}
} catch (e) { return; }
// Nome dos clientes (cache incremental — 1 query só pros que faltam)
const faltam = [...new Set(rows.map(r => r.cliente_id).filter(id => id && !_distPainelClientes.has(id)))];
if (faltam.length) {
try {
const { data: cls } = await sb.from('clientes').select('id,nome').in('id', faltam);
(cls || []).forEach(c => _distPainelClientes.set(c.id, c.nome || 'cliente'));
} catch (e) { /* cai no fallback 'cliente' */ }
}
// Notificação desktop + toast SÓ na transição de estado (não no 1º load da página).
// Só no fluxo vivo — na aba Arquivadas não há transições a notificar.
if (_distFiltro !== 'arquivadas') rows.forEach(r => {
const ant = _distPainelStatus.get(r.id);
const { chave } = _distEstado(r);
if (ant && ant !== chave) {
const nome = _distPainelClientes.get(r.cliente_id) || 'cliente';
if (chave === 'pronta') {
toast(`✅ Petição inicial de ${nome} PRONTA — revise e assine com o A3 na aba do tribunal.`, 'success');
try { if (window.Notification && Notification.permission === 'granted') new Notification('IURIA — Petição inicial pronta', { body: `A inicial de ${nome} está pronta no tribunal. Revise e assine com o A3.` }); } catch (e) {}
} else if (chave === 'pendencia' || chave === 'erro') {
toast(`⚠ A distribuição de ${nome} parou — veja a pendência no card.`, 'warning');
try { if (window.Notification && Notification.permission === 'granted') new Notification('IURIA — Distribuição precisa de você', { body: `A inicial de ${nome} parou numa pendência. Veja em Protocolos.` }); } catch (e) {}
}
}
_distPainelStatus.set(r.id, chave);
});
_distPainelRender(el, rows);
}
function _distPainelRender(el, rows) {
if (!el) return;
const limpaPend = (p) => _protoFmtMsg(String(p).replace(/^Passo \d+:?\s*/, ''));
// Classifica cada linha na aba certa.
const items = (rows || []).map(r => ({ r, est: _distEstado(r) }));
const isArq = x => x.r.status === 'arquivada';
// PRONTA fica em PENDENTES (pedido do George 23/07): o robô terminou, mas AINDA falta o
// advogado assinar com o A3 — a pendência é dele. Só vai pra Concluídas quando ele clica
// "✓ Assinei e protocolei" (status 'concluida'), igual ao "Já protocolei" da fila de peças.
const isConc = x => !isArq(x) && x.est.chave === 'concluida'; // assinada e protocolada (confirmada)
const isPend = x => !isArq(x) && x.est.chave !== 'concluida'; // inclui a PRONTA (falta assinar)
const cntPend = items.filter(isPend).length;
const cntConc = items.filter(isConc).length;
const visiveis = _distFiltro === 'arquivadas' ? items.filter(isArq)
: _distFiltro === 'concluidas' ? items.filter(isConc)
: items.filter(isPend);
// Painel some só se NUNCA houve distribuição (nada vivo/recente) e o filtro é o padrão.
if (!items.length && _distFiltro === 'pendentes') { el.innerHTML = ''; return; }
const cardHtml = ({ r, est }) => {
const nome = _distPainelClientes.get(r.cliente_id) || 'cliente';
const prog = r.progresso || {};
const de = Math.max(1, Number(prog.de) || 5);
const passo = Math.max(0, Math.min(de, Number(prog.passo) || 0));
const { chave, pend } = est;
const arquivada = r.status === 'arquivada';
const acesos = (chave === 'pronta' || chave === 'concluida') ? de : passo;
let chip, msg;
if (arquivada) {
chip = `🗄 arquivada`;
msg = `
Arquivada — fora do fluxo. Dá pra desarquivar (volta pra Pendentes) ou excluir de vez.
Assinada com o A3 e protocolada — confirmada por você.
`;
} else if (chave === 'pronta') {
chip = `✅ PRONTA — falta assinar`;
msg = `
Pronta — revise e assine com o A3 na aba do tribunal. Use "Salvar para Distribuição Futura" e assine — o IURIA não assina por você. Depois de assinar, volte aqui e clique "✓ Assinei e protocolei" pra mover para Concluídas.
`;
} else if (chave === 'erro') {
chip = `✗ não concluiu`;
msg = pend.length
? `
Parou com erro:
${pend.map(p => `
${limpaPend(p)}
`).join('')}
`
: `
Falha na preparação — veja os Detalhes ou reenvie.
`;
} else if (chave === 'pendencia') {
chip = `⚠ requer sua ação`;
msg = pend.length
? `
Parou aguardando você:
${pend.map(p => `
${limpaPend(p)}
`).join('')}
`
: `
Parou numa pendência — abra os Detalhes.
`;
// FALTOU O CNPJ DA PARTE ADVERSA (pedido do George 23/07). O robô procura o CNPJ
// 1º no cadastro do IURIA e 2º na petição inicial; quando não acha nos dois, para.
// Em vez de mandar o advogado ao cadastro e reenviar, o campo aparece AQUI: ele
// digita, clica "OK, continuar" e o robô retoma sozinho até a assinatura.
if (/parte adversa/i.test(pend.join(' '))) {
msg += `
Informe o CNPJ da parte adversa e eu continuo:
Salvo na parte adversa e o robô retoma na hora — para na sua assinatura (A3).
`;
}
} else {
chip = `⏳ preparando…`;
const rotulo = prog.rotulo || (r.status === 'pronto' ? 'Na fila — o robô vai abrir o tribunal' : 'Montando o processo no tribunal');
msg = `
Passo ${Math.max(1, passo)}/${de} — ${escape(rotulo)}. Para na assinatura (A3 é seu).
`;
}
// Arquivar/Excluir DISTRIBUIÇÃO (cards do painel — pedido do George 12/07): cards que "não
// concluíram"/"requerem ação" e o advogado decidiu abandonar. Arquivar = status 'arquivada'
// (some do painel, registro fica); Excluir = apaga a linha de vez.
// CONCLUIR = o ADVOGADO confirma que assinou (A3) e protocolou (pedido do George 23/07).
// A card PRONTA fica em Pendentes (a assinatura é humana, ainda falta) e só vai para
// Concluídas neste clique — o mesmo modelo do "Já protocolei" da fila de protocolo.
async function distConcluir(id) {
if (!confirm('Confirma que você JÁ ASSINOU com o A3 e protocolou no tribunal?\n\nA distribuição vai para a aba Concluídas.')) return;
const { error } = await sb.from('distribuicoes').update({ status: 'concluida', updated_at: new Date().toISOString() }).eq('id', id);
if (error) { toast('Erro ao concluir: ' + error.message, 'danger'); return; }
_distPainelStatus.delete(id);
toast('✅ Distribuição concluída — assinada e protocolada');
try { _distPainelRefetch(); } catch (e) {}
}
// INFORMAR O CNPJ DA PARTE ADVERSA e RETOMAR (pedido do George 23/07).
// Ordem de busca do réu na distribuição: (1) cadastro do IURIA → (2) petição inicial
// (extração por IA no watcher) → (3) se não achou nos dois, o robô PARA e pede aqui.
// Este botão grava o documento na parte passiva e re-enfileira (status='pronto'):
// o robô refaz a distribuição e vai até a assinatura. O A3 continua sendo seu.
async function distInformarCnpjReu(id) {
const elC = document.getElementById('distCnpj_' + id);
const elCep = document.getElementById('distCep_' + id);
const doc = String((elC && elC.value) || '').replace(/\D/g, '');
const cepDig = String((elCep && elCep.value) || '').replace(/\D/g, '');
if (doc.length !== 14 && doc.length !== 11) {
toast('Informe um CNPJ (14 dígitos) ou CPF (11 dígitos) da parte adversa', 'danger');
return;
}
try {
const { data: row, error: e1 } = await sb.from('distribuicoes').select('partes').eq('id', id).maybeSingle();
if (e1 || !row) { toast('Não achei esta distribuição' + (e1 ? ': ' + e1.message : ''), 'danger'); return; }
const partes = row.partes || {};
const passivo = Array.isArray(partes.passivo) ? partes.passivo : [];
if (!passivo.length) { toast('Esta distribuição não tem parte adversa cadastrada', 'danger'); return; }
// preenche o 1º réu SEM documento (é exatamente o que travou o robô)
const alvo = passivo.find(p => !String((p && (p.cpf_cnpj || p.cnpj || p.cpf)) || '').replace(/\D/g, '')) || passivo[0];
alvo.cpf_cnpj = doc;
if (!String(alvo.tipo_pessoa || '').trim()) alvo.tipo_pessoa = doc.length === 14 ? 'PJ' : 'PF';
if (cepDig.length === 8 && !String(alvo.cep || '').trim()) alvo.cep = cepDig.slice(0, 5) + '-' + cepDig.slice(5);
const { error: e2 } = await sb.from('distribuicoes').update({
partes,
status: 'pronto',
resultado: null,
progresso: { de: 5, passo: 0, pronta: false, rotulo: 'Na fila — CNPJ da parte adversa informado', pendencias: [] },
updated_at: new Date().toISOString(),
}).eq('id', id);
if (e2) { toast('Erro ao salvar: ' + e2.message, 'danger'); return; }
toast('CNPJ salvo — o robô retomou a distribuição');
try { _distPainelRefetch(); } catch (e) {}
} catch (e) {
toast('Erro: ' + (e && e.message ? e.message : e), 'danger');
}
}
async function distArquivar(id) {
if (!confirm('Arquivar esta distribuição? Ela sai do painel, mas o registro é mantido.')) return;
const { error } = await sb.from('distribuicoes').update({ status: 'arquivada', updated_at: new Date().toISOString() }).eq('id', id);
if (error) { toast('Erro ao arquivar: ' + error.message, 'danger'); return; }
_distPainelStatus.delete(id);
toast('Distribuição arquivada — saiu do painel');
try { _distPainelRefetch(); } catch (e) {}
}
async function distExcluir(id) {
if (!confirm('Excluir DEFINITIVAMENTE esta distribuição?\n\nO registro é apagado (o rascunho no tribunal, se houver, não é afetado).')) return;
const { error } = await sb.from('distribuicoes').delete().eq('id', id);
if (error) { toast('Erro ao excluir: ' + error.message, 'danger'); return; }
_distPainelStatus.delete(id);
toast('Distribuição excluída');
try { _distPainelRefetch(); } catch (e) {}
}
views.protocolo = async (main) => {
// Supabase Realtime: assina mudanças na protocolo_fila e re-renderiza
// instantaneamente quando watcher atualiza algum item.
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 () => {
// Guarda pela presença da tabela (não pelo nav .active): em aba aberta por
// deep-link (?view=protocolo) o botão não fica .active, mas a view existe.
if (!document.getElementById('tbProto')) { try { sb.removeChannel(_protoRealtimeCh); } catch(e){} _protoRealtimeCh = null; return; }
await _protoRefetch();
})
// Distribuições (petição inicial): o painel de acompanhamento ao vivo reage
// na hora que o engine local atualiza progresso/status da linha.
.on('postgres_changes', { event: '*', schema: 'public', table: 'distribuicoes' }, async () => {
if (!document.getElementById('distPainel')) return;
try { await _distPainelRefetch(); } catch (e) {}
})
.subscribe();
// POLLING de segurança (7s): garante que itens recém-enviados e as transições
// de status do watcher (gerando PDF → abrindo tribunal → aguardando login →
// pronto) apareçam mesmo se o Realtime perder um evento. Auto-encerra ao sair.
if (_protoPollHandle) { clearInterval(_protoPollHandle); _protoPollHandle = null; }
_protoPollHandle = setInterval(async () => {
if (!document.getElementById('tbProto')) { clearInterval(_protoPollHandle); _protoPollHandle = null; return; }
await _protoRefetch();
}, 7000);
main.innerHTML = `
${fila.length} item(ns) · ${aprovados} aprovado(s) prontos pra protocolar
Como funciona:
(1) Você revisa a peça e clica em ${ic('upload')} Enviar p/ protocolo;
(2) o sistema prepara o documento em poucos segundos;
(3) abre sozinho a aba do tribunal, com a peça já anexada e ${ic('hourglass')} pronta pra assinar;
(4) você só assina na aba já preparada, com o seu certificado;
(5) volta aqui e clica em "Já protocolei".
`;
_protoRenderTabela(document.getElementById('tbProto'), fila);
// Painel de distribuições (petição inicial) — acompanhamento ao vivo no topo.
try { await _distPainelRefetch(); } catch (e) {}
};
// Renderiza texto vindo do watcher que pode trazer **negrito** (marcadores do
// watcher_protocolo pra destacar o campo específico que falta preencher). Faz
// escape de HTML PRIMEIRO (segurança) e só então converte os pares **...** em
// . Usado no bloco "aguardando preenchimento" da fila de protocolo.
function _protoFmtMsg(txt) {
return escape(String(txt || '')).replace(/\*\*([^*]+)\*\*/g, '$1');
}
function _protoRenderTabela(el, fila) {
if (!el) return;
fila = (fila || []).filter(r => r.status !== 'arquivado'); // arquivados saem da lista (pedido George)
if (!fila.length) {
el.innerHTML = emptyState({ icon: 'upload', titulo: 'Fila vazia', subtitulo: '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)
Instância
Sistema
Status
${fila.map(r => {
let st = _PROTO_STATUS[r.status] || ['', r.status];
// Peça preparada mas com pendência de preenchimento (Tipo do anexo,
// classificação, solicitante): o chip não pode dizer "✅ PRONTO" — troca
// pra amarelo "falta preencher" pra não dar falsa sensação de pronto.
if (r.status === 'aguardando_assinatura'
&& (r.resultado?.aguardando_preenchimento
|| (Array.isArray(r.resultado?.pendencias_detalhe) && r.resultado.pendencias_detalhe.length)
|| r.resultado?.solicitante_pendente)) {
st = ['warning', '⏳ pronto — falta você preencher (PIN A3 depois)'];
}
// Aguardando AÇÃO do advogado no tribunal (login ou abrir o processo p/ peticionar):
// o chip "preparando" engana (parece que está rodando). Troca pra um chip de ação.
if ((r.status === 'aprovado' || r.status === 'aguardando_pdf') && r.tipo === 'protocolo'
&& !r.resultado?.bloqueado_peticionamento
&& (r.resultado?.aguardando_navegacao || r.resultado?.aguardando_login)) {
st = ['warning', r.resultado?.aguardando_navegacao ? '📍 abra o processo e clique Peticionar' : '🔑 faça login no tribunal'];
}
// FALHA DE PREPARO que NÃO é login: tribunal instável (500/lento) ou outro
// motivo real. O chip NÃO pode dizer "faça login" nem ficar como "preparando"
// — mostra que não concluiu, com o motivo honesto logo abaixo (sem mandar logar).
if ((r.status === 'aprovado' || r.status === 'aguardando_pdf') && r.tipo === 'protocolo'
&& !r.resultado?.bloqueado_peticionamento && !r.resultado?.aguardando_login && !r.resultado?.aguardando_navegacao
&& (r.resultado?.erro_preparo || r.resultado?.tribunal_instavel)) {
st = ['danger', r.resultado?.tribunal_instavel ? '⚠ tribunal instável — re-tentando' : '✗ não preparei (não é login)'];
}
const podeRevisar = r.tipo === 'protocolo' && r.status === 'aguardando_revisao';
const isTjsp = /^\d{7}-\d{2}\.\d{4}\.8\.26\.\d{4}$/.test(r.numero_mascara || '');
// Nº do processo clicável → abre a PASTA DO PROCESSO no IURIA (autos/íntegra/
// anexos), em nova aba via abrirProcessoNoApp. Só quando há processo_id cadastrado.
const _procNum = r.numero_mascara || r.numero_processo || '-';
const _temProc = !!r.processo_id;
// INSTÂNCIA (somente leitura) — definida automaticamente na geração da peça
// (sem escolha manual de 1º/2º grau). A Turma/Colégio Recursal do Juizado é
// a instância recursal; no federal ela roda no pje1g, mas NÃO se chama "1º grau".
const _fedTR = (r.numero_mascara || '').replace(/\D/g, '').charAt(13) === '4' || /^TRF/.test((r.tribunal || '').toUpperCase());
let _instLabel, _instCor;
if (r.instancia === 'turma_recursal') { _instLabel = 'Turma Recursal' + (_fedTR ? '' : ' ⚠'); _instCor = '#1E40AF'; }
else if (r.instancia === 'segundo') { _instLabel = '2º grau'; _instCor = '#6B21A8'; }
else if (r.instancia === 'superior') { _instLabel = 'Superior'; _instCor = '#0F766E'; }
else if (r.instancia === 'primeiro') { _instLabel = '1º grau'; _instCor = '#374151'; }
else { _instLabel = (r.grau === '2g' ? '2º grau' : '1º grau'); _instCor = '#9CA3AF'; } // itens antigos sem instância
const grauCel = `${escape(_instLabel)}`;
// Sistema: dropdown só pra TJSP. Outros tribunais mostram o sistema
// (PJE por padrão) num chip legível, em vez do traço apagado.
const sysCel = !isTjsp
? `${escape((r.sistema || 'pje').toUpperCase())}`
: podeRevisar
? ``
: escape(r.sistema || 'esaj');
const verPdf = r.arquivo_path
? `` : '';
// Ações por status
let acoes = '';
if (podeRevisar) {
acoes = `${verPdf}`;
} else if ((r.status === 'aguardando_pdf' || r.status === 'aprovado') && r.tipo === 'protocolo') {
if (r.resultado?.bloqueado_peticionamento) {
// PJe bloqueou o processo p/ peticionamento (cadeado). Não é login —
// o advogado resolve no tribunal (habilitação/etc.) e clica "Já resolvi".
acoes = `${verPdf}
`;
} else if (r.resultado?.aguardando_login || r.resultado?.aguardando_navegacao) {
// Watcher pausou por falta de AÇÃO no tribunal: login (sem sessão) OU
// navegação (PJe já logado, mas precisa abrir o processo e clicar Peticionar).
const _nav = r.resultado?.aguardando_navegacao;
acoes = `${verPdf}
`;
} else if (r.resultado?.erro_preparo || r.resultado?.tribunal_instavel) {
// FALHA DE PREPARO que NÃO é login (tribunal instável/500, grade, classificação,
// número do processo etc.). NÃO oferece "fazer login" — oferece tentar de novo
// (re-spawna o autopet no próximo ciclo) e abrir o tribunal pra conferir manual.
acoes = `${verPdf}
`;
} else {
// Já foi aprovado mas watcher ainda não terminou — dá chance de cancelar antes.
acoes = `${verPdf}`;
}
} else if (r.status === 'aguardando_assinatura' && r.resultado) {
// Peça pronta com PENDÊNCIA (falta dado, ex.: qualificação do cliente):
// oferece "Re-enviar pro protocolo" pra reprocessar o MESMO item com os
// dados atualizados (re-enfileira: status='aprovado' + retry_login).
const _det = Array.isArray(r.resultado?.pendencias_detalhe) ? r.resultado.pendencias_detalhe
: (Array.isArray(r.resultado?.pendencias_preparo) ? r.resultado.pendencias_preparo : null);
const _temPend = r.resultado?.aguardando_preenchimento || (_det && _det.length) || r.resultado?.solicitante_pendente;
const btnReenviar = _temPend
? ``
: '';
acoes = `${verPdf}
${btnReenviar}
`;
} else {
acoes = verPdf;
}
// EXCLUIR / ARQUIVAR (pedido do George): todo item que ERROU ou NÃO CONCLUIU ganha as duas
// opções — arquivar (some da lista, guarda o registro) e excluir (apaga de vez). Não
// aparece em itens já protocolados nem nos que estão prontos aguardando sua assinatura.
const _naoConcluiu = r.status === 'erro' || r.status === 'reprovado'
|| r.resultado?.erro_preparo || r.resultado?.tribunal_instavel || r.resultado?.autopet_erro
|| r.resultado?.bloqueado_peticionamento
|| (r.tipo === 'protocolo' && (r.status === 'aprovado' || r.status === 'aguardando_pdf')
&& (r.resultado?.aguardando_login || r.resultado?.aguardando_navegacao));
if (_naoConcluiu) {
acoes += `
`;
}
return `
${_temProc ? `` : ''}${escape(_procNum)}${_temProc ? '' : ''}${r.numero_incidente ? ` ⚠ INCIDENTE ${escape(r.numero_incidente)} — protocolar NO incidente, não no processo de origem` : ''}${r.observacao ? ` ${escape((r.observacao || '').slice(0, 90))}` : ''}
${escape(r.nome_parte || '-')}
${grauCel}
${sysCel}
${escape(st[1])}${(r.resultado?.aguardando_login && r.status !== 'aguardando_assinatura' && r.status !== 'protocolado') ? ` 🔑 Faça login no tribunal — ${escape(r.resultado?.mensagem || `Confira se você está logado no ${r.tribunal || 'tribunal'}${(r.resultado?.grau_detectado || r.grau) ? ' (' + (r.resultado?.grau_detectado || r.grau) + ')' : ''} no Chrome de automação (:9222). Abri a página de login do grau correto; faça login que o protocolo segue sozinho.`)}` : ''}${(r.resultado?.aguardando_navegacao && r.status !== 'aguardando_assinatura' && r.status !== 'protocolado') ? ` 📍 Abra o processo no tribunal e clique Peticionar — ${escape(r.resultado?.mensagem || 'Você já está logado. Abra o processo no tribunal e clique em Peticionar — eu anexo a peça, classifico e paro no PIN A3.')}` : ''}${(r.resultado?.aguardando_incidente && r.status !== 'aguardando_assinatura' && r.status !== 'protocolado') ? ` ⚠ ${escape(r.resultado?.mensagem || 'Incidente não localizado no eSAJ — confira o número.')}` : ''}${(r.resultado?.bloqueado_peticionamento && r.status !== 'aguardando_assinatura' && r.status !== 'protocolado') ? ` ${ic('ban')} ${escape(r.resultado?.mensagem || 'Processo bloqueado para peticionamento no tribunal — verifique habilitação/segredo e peticione manualmente.')}` : ''}${r.resultado?.integra_nao_suportada ? ` ${ic('download')} ${escape(r.resultado?.mensagem || 'Íntegra automática ainda não disponível para este tribunal (PJe) — baixe os autos manualmente. Hoje cobre só TJSP/eSAJ e TJSC/eproc.')}` : ''}${(r.status === 'erro' && !r.resultado?.bloqueado_peticionamento && !r.resultado?.aguardando_login && !r.resultado?.integra_nao_suportada) ? ` ✗ Não concluiu — ${_protoFmtMsg(r.resultado?.mensagem || r.resultado?.erro || 'Falha não detalhada — reenvie; se persistir, confira login e número do processo no tribunal.')}` : ''}${((r.status === 'aprovado' || r.status === 'aguardando_pdf') && (r.resultado?.erro_preparo || r.resultado?.tribunal_instavel) && !r.resultado?.bloqueado_peticionamento && !r.resultado?.aguardando_login && !r.resultado?.aguardando_navegacao) ? (r.resultado?.tribunal_instavel ? ` ⚠ Tribunal instável — ${_protoFmtMsg(r.resultado?.mensagem || 'O sistema do tribunal está lento/instável agora. Vou tentar de novo automaticamente — não é problema de login.')}` : ` ✗ Não preparei a peça (não é login) — ${_protoFmtMsg(r.resultado?.mensagem || r.resultado?.autopet_erro || 'Falha no preparo — confira o número do processo/cadastro e clique Tentar de novo.')}`) : ''}${(() => {
// PEÇA PRONTA, MAS FALTA PREENCHER CAMPO ESPECÍFICO na aba do tribunal.
// Quando o autopet montou a peça (PDF + NPU + classificação) mas o eSAJ
// ainda exige algo do advogado (Tipo do documento do anexo, confirmar a
// classificação, qualificação do solicitante terceiro), o watcher grava
// aguardando_preenchimento + pendencias_detalhe. Aqui mostramos COM DESTAQUE
// o que falta — nunca um chip genérico. Não é "faça login": a peça está pronta.
if (r.status !== 'aguardando_assinatura') return '';
const det = Array.isArray(r.resultado?.pendencias_detalhe) ? r.resultado.pendencias_detalhe
: (Array.isArray(r.resultado?.pendencias_preparo) ? r.resultado.pendencias_preparo : null);
const temPend = r.resultado?.aguardando_preenchimento || (det && det.length) || r.resultado?.solicitante_pendente;
if (!temPend) return '';
// Algumas pendências se resolvem NO CADASTRO DO CLIENTE (qualificação do
// solicitante: estado civil, profissão, RG, endereço…), não na aba do
// tribunal. Detecta esse caso pra dar a instrução certa + link pro cadastro.
const txtPend = ((det && det.length ? det.join(' ') : '') + ' ' + (r.resultado?.mensagem || '')).toLowerCase();
const ehCadastroCliente = !!r.resultado?.solicitante_pendente || /solicitante|estado civil|profiss|qualifica|nacionalidade|\brg\b|endere|cpf|cnpj/.test(txtPend);
const itensHtml = (det && det.length)
? `
${det.map(d => `
${_protoFmtMsg(d)}
`).join('')}
`
: (r.resultado?.mensagem ? `
${_protoFmtMsg(r.resultado.mensagem)}
` : '');
if (ehCadastroCliente) {
// Falta dado na ficha do cliente: aponta o CADASTRO e oferece abrir a ficha
// (resolve via processo_id → cliente) + lembra de clicar Re-enviar depois.
const btnCad = r.processo_id
? ``
: '';
return ` ⏳ Falta preencher no CADASTRO DO CLIENTE (não na aba do tribunal):${itensHtml}
Preencha no cadastro do cliente e clique ${ic('refresh')} Re-enviar pro protocolo — eu refaço a peça com os dados atualizados e paro no PIN A3.
${btnCad}`;
}
return ` ⏳ Aguardando você preencher na aba do tribunal antes do PIN A3:${itensHtml}`;
})()}
${acoes}
`;
}).join('')}
`;
// Popup (1x por item por sessão) avisando que falta login no tribunal. Mostra
// pro 1º item pendente ainda não avisado; marca todos os pendentes como avisados
// pra não repetir a cada refresh (7s).
try {
const pendentes = (fila || []).filter(r => r.resultado?.aguardando_login && r.status !== 'aguardando_assinatura' && r.status !== 'protocolado' && !_protoLoginAlertados.has(r.id));
if (pendentes.length) {
const primeiro = pendentes[0];
pendentes.forEach(r => _protoLoginAlertados.add(r.id));
_protoPopupLogin(primeiro);
}
} catch (e) { /* popup é best-effort */ }
}
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');
}
// Abre a página de login/peticionamento do tribunal no grau correto. Prefere a
// URL exata que o watcher já abriu no Chrome :9222 (aba_chrome_url); se não tiver,
// monta pelo NPU. Abre em nova aba — se o app roda DENTRO do Chrome :9222, é lá
// que o advogado precisa logar.
async function protoAbrirTribunalLogin(id) {
const w = window.open('', '_blank'); // abre no gesto do clique (evita bloqueio de pop-up); navega após o fetch
try {
const { data } = await sb.from('protocolo_fila').select('resultado, numero_mascara, tribunal').eq('id', id).single();
let url = data?.resultado?.aba_chrome_url || data?.resultado?.tribunal_url || '';
if (!url || url === 'about:blank') {
const alias = inferirAliasTribunal(data?.numero_mascara || '');
url = linkPortalTribunal(data?.numero_mascara || '', alias) || '';
}
if (!url) { if (w) w.close(); toast('Não consegui montar o link do tribunal — abra manualmente no Chrome :9222.', 'danger'); return; }
if (w) w.location.href = url; else window.open(url, '_blank');
} catch (e) { if (w) { try { w.close(); } catch(_){} } toast('Erro ao abrir tribunal: ' + (e?.message || e), 'danger'); }
}
// "Já fiz o login — avançar": sinaliza o watcher pra retomar o protocolo AGORA
// (status segue 'aprovado'; some o banner). retry_login fura o cooldown de 1h que
// o watcher aplica após 3 tentativas de login frustradas.
async function protoJaLoguei(id) {
const { data } = await sb.from('protocolo_fila').select('resultado').eq('id', id).single();
const novo = { ...(data?.resultado || {}) };
novo.aguardando_login = false;
novo.aguardando_incidente = false;
novo.bloqueado_peticionamento = false; // se resolveu (habilitação etc.), tenta de novo
novo.retry_login = true; // ← watcher lê isso, tenta agora e ignora o cooldown
delete novo.autopet_erro;
const { error } = await sb.from('protocolo_fila').update({ resultado: novo }).eq('id', id);
if (error) { toast('Erro: ' + error.message, 'danger'); return; }
_protoLoginAlertados.delete(id); // se cair de novo em "falta login", avisa outra vez
closeModal();
toast('Combinado — vou retomar o protocolo no próximo ciclo (poucos segundos). Acompanhe o status aqui; para no PIN A3 pra você assinar.', 'success');
_protoRefetch();
}
// RE-ENVIAR PRO PROTOCOLO: reprocessa o MESMO item da fila com os dados atualizados
// (ex.: depois de o advogado completar a qualificação no cadastro do cliente).
// Re-enfileira sem duplicar: volta o status pra 'aprovado' (que é o que o
// watcher_protocolo processa) + retry_login=true (o watcher lê isso, fura o
// cooldown de falhas e roda AGORA) e LIMPA as flags de pendência/preparo do ciclo
// anterior pra a UI não seguir mostrando "falta preencher" enquanto reprocessa.
async function protoReenviar(id) {
const { data } = await sb.from('protocolo_fila').select('resultado').eq('id', id).single();
const novo = { ...(data?.resultado || {}) };
// dispara o reprocessamento no watcher
novo.retry_login = true; // ← fura cooldown e roda no próximo ciclo
// limpa o resultado do preparo anterior pra o watcher montar do zero
novo.aguardando_login = false;
novo.aguardando_incidente = false;
novo.bloqueado_peticionamento = false;
novo.aguardando_preenchimento = false;
novo.solicitante_pendente = false;
novo.solicitante_faltando = null;
novo.pendencias_preparo = null;
novo.pendencias_detalhe = null;
novo.erro_preparo = false; // limpa falha de preparo (não-login) do ciclo anterior
novo.tribunal_instavel = false;
delete novo.autopet_erro;
delete novo.mensagem;
const { error } = await sb.from('protocolo_fila')
.update({ status: 'aprovado', resultado: novo }).eq('id', id);
if (error) { toast('Erro ao re-enviar: ' + error.message, 'danger'); return; }
_protoLoginAlertados.delete(id);
toast('Re-enviado — vou refazer a peça com os dados atualizados no próximo ciclo (poucos segundos) e parar no PIN A3.', 'success');
_protoRefetch();
}
// Abre o CADASTRO do cliente dono deste item da fila (resolve via processo_id →
// processos.cliente_id). Usado no aviso de pendência que se resolve na ficha do
// cliente (qualificação do solicitante: estado civil, profissão, RG, endereço…).
async function protoAbrirCadastroCliente(processoId) {
if (!processoId) { toast('Sem processo vinculado a este item.', 'warning'); return; }
let clienteId = null;
// 1) tenta o cache local (IndexedDB) — mais rápido e offline-tolerante
try { const p = await dbGet('processos', processoId); if (p) clienteId = p.clienteId || p.cliente_id || null; } catch (e) {}
// 2) fallback Supabase
if (!clienteId) {
try { const { data } = await sb.from('processos').select('cliente_id').eq('id', processoId).single(); clienteId = data?.cliente_id || null; } catch (e) {}
}
if (!clienteId) { toast('Não achei o cliente deste processo. Abra pela aba Clientes.', 'warning'); return; }
closeModal();
abrirCliente(clienteId);
}
// Corrige o grau de um item ainda pendente (1g↔2g) e pede retry ao watcher pra
// retomar no grau/tribunal certo.
async function protoSetGrau(id, grau) {
const { data } = await sb.from('protocolo_fila').select('resultado, status').eq('id', id).single();
const novo = { ...(data?.resultado || {}) };
novo.aguardando_login = false;
novo.retry_login = true; // se já tinha falhado, fura o cooldown e retoma no grau novo
const { error } = await sb.from('protocolo_fila').update({ grau, resultado: novo }).eq('id', id);
if (error) { toast('Erro ao salvar o grau: ' + error.message, 'danger'); return; }
toast(`Grau ajustado p/ ${grau === '2g' ? '2º grau' : '1º grau'} — vou retomar no tribunal correto.`, 'success');
_protoLoginAlertados.delete(id);
_protoRefetch();
}
// Popup (1x por item por sessão) avisando que falta login no tribunal, com botões
// pra abrir o tribunal e pra confirmar "já loguei".
function _protoPopupLogin(item) {
const trib = item.tribunal || 'tribunal';
const grau = item.resultado?.grau_detectado || item.grau || '';
const msg = item.resultado?.mensagem || `Confira se você está logado no ${trib}${grau ? ' (' + grau + ')' : ''} no Chrome de automação (:9222). Faça o login que o protocolo segue sozinho.`;
const body = `
Pra continuar o protocolo da petição de ${escape(item.nome_parte || '')} (${escape(item.numero_mascara || '')}), falta fazer login no tribunal.
${escape(msg)}
Clique “Abrir tribunal pra logar”, faça o login com o token e depois clique “Já fiz o login — avançar”. Eu retomo sozinho e paro no PIN A3 pra você assinar.
`;
const footer = `
`;
openModal('🔑 Login necessário no tribunal', body, footer);
}
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 sysSel = document.getElementById('ps_' + id);
const update = { status: 'aprovado' };
if (sel) update.grau = sel.value; // só sobrescreve se houver dropdown (não há mais por padrão — grau vem da peça)
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');
}
// Confirmação humana pós-assinatura (NOSIGN: o advogado assina+protocola no tribunal).
// Marca o item como 'protocolado' com carimbo de data/hora, tirando-o do "aguardando
// assinatura" eterno — assim dá pra ver o que já foi protocolado e o que ficou pendente
// (evita peça preparada e esquecida). Preserva o resultado existente.
async function confirmarProtocolado(id) {
if (!confirm('Confirma que esta peça foi ASSINADA e PROTOCOLADA no tribunal?\n\nVou marcar como "protocolado" (sai de "aguardando assinatura").')) return;
let resultadoAtual = {};
try { const { data } = await sb.from('protocolo_fila').select('resultado').eq('id', id).single(); resultadoAtual = data?.resultado || {}; } catch (e) {}
const novo = Object.assign({}, resultadoAtual, { protocolado_em: new Date().toISOString(), protocolado_via: 'confirmacao_manual' });
const { error } = await sb.from('protocolo_fila').update({ status: 'protocolado', resultado: novo }).eq('id', id);
if (error) { toast('Erro ao confirmar: ' + error.message, 'danger'); return; }
toast('✅ Protocolo confirmado', 'success');
renderView('protocolo');
}
async function protoExcluir(id, path) {
if (!confirm('Excluir DEFINITIVAMENTE este item da fila de protocolo?\n\nO registro é apagado. (A peça em PDF, se houver, também.)')) 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');
}
// Arquivar (pedido do George): tira o item travado/errado da lista SEM apagar — vira status
// 'arquivado' e o render passa a esconder. Útil pra protocolos que não concluíram e você quer
// só "limpar a tela" mantendo o histórico.
async function protoArquivar(id) {
if (!confirm('Arquivar este item? Ele sai da lista de protocolos, mas o registro é mantido.')) return;
const { error } = await sb.from('protocolo_fila').update({ status: 'arquivado' }).eq('id', id);
if (error) { toast('Erro ao arquivar: ' + error.message, 'danger'); return; }
toast('Item arquivado — saiu da lista');
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 ? `
${lista.length} publicação(ões) — mais recente em cima
${tabela}
`;
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 = `
Relatórios
Visão executiva do escritório · período: mês corrente
${[
{id:'clientes', label:'👥 Clientes'},
{id:'processos', label:'📂 Processos'},
{id:'financeiro', label:'💰 Financeiro'},
{id:'ia', label:'🪶 Custos IA'},
// Sub-aba "Lucro IA" — EXCLUSIVA DO DONO (George): receita×custo real×lucro.
// O botão só existe se ehOwner() (não-dono nunca vê a aba); o dado vem
// de rpc_lucro_ia_mensal, que é gated server-side por app_is_owner().
...(ehOwner() ? [{id:'lucro', label:'📈 Lucro IA'}] : []),
{id:'performance', label:'⚡ Performance'},
...(ehOwner() ? [{id:'produtividade', label:'📊 Produtividade'}] : []),
].map(t => ``).join('')}
`;
const el = document.getElementById('relContent');
el.innerHTML = '
Carregando…
';
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 === 'lucro') { if (ehOwner()) await relRenderLucroIA(el); else await relRenderClientes(el); }
else if (_relAba === 'performance') await relRenderPerformance(el);
else if (_relAba === 'produtividade') { if (ehOwner()) await relRenderProdutividade(el); else await relRenderClientes(el); }
} catch (e) {
el.innerHTML = `
⚠
Erro carregando relatório
${escape(e.message || e)}
`;
}
};
// ----- Helper de cards -----
function relCard(label, value, meta, color) {
return `
${escape(label)}
${value}
${meta ? `
${meta}
` : ''}
`;
}
// ----- 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 = `
${relCard('Total de clientes', clientes.length, 'PF: '+pf+' · PJ: '+pj)}
${relCard('Novos este mês', novosMes, `${novosAno} no ano de ${ano}`, '#10B981')}
${relCard('Com processo ativo', procsByCli.size, `${((procsByCli.size/clientes.length)*100||0).toFixed(0)}% do total`)}
${relCard('Em algum grupo', clientes.filter(c => c.grupoId || c.grupo_id).length, `${grupos.length} grupos cadastrados`)}
Distribuição por grupo
${porGrupoArr.length ? `
Grupo
Qtd
%
${porGrupoArr.map(g => `
${escape(g.nome)}
${g.qtd}
${((g.qtd/clientes.length)*100).toFixed(1)}%
`).join('')}
` : '
📊
Sem dados
'}
Top 10 clientes mais ativos
${topAtivos.length ? `
Cliente
Processos
${topAtivos.map(x => `
${escape(x.c.nome)}
${x.q}
`).join('')}
` : '
📂
Sem processos cadastrados ainda
'}
`;
}
// ----- 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 = `
${relCard('Total de processos', total, 'todos os tempos')}
${relCard('Ativos', ativos, `${((ativos/total)*100||0).toFixed(0)}% do total`, '#10B981')}
${relCard('Encerrados', encerrados, `${arquivados} arquivados`)}
${relCard('Idade média', idadeMed > 365 ? `${(idadeMed/365).toFixed(1)} anos` : `${idadeMed} dias`, 'desde a distribuição')}
Use a aba Financeiro pra lançar receitas e despesas.
'}
`;
}
// ----- 4) CUSTOS IA -----
// Agora servido pela RPC gated rpc_custo_ia_resumo (server-side):
// - NÃO-DONO: tabela mostra só o PREÇO (com spread) por feature/período.
// Nunca custo real em USD nem o fator.
// - DONO: mostra custo real (BRL) + receita (preço) + lucro.
async function relRenderIA(el) {
let resumo = null;
try {
const { data } = await sb.rpc('rpc_custo_ia_resumo');
resumo = data;
} catch (e) { resumo = null; }
if (!resumo || !Array.isArray(resumo.features)) {
el.innerHTML = '
${relCard('Custo de IA este mês', brl(tMes), tQtd + ' chamadas', '#00E5FF')}
${relCard('Custo de IA este ano', brl(tAno), '', '#0099B8')}
${relCard('Custo médio / chamada', brl(tQtd ? tAll/tQtd : 0), '')}
${relCard('Total geral', brl(tAll), '')}
Quebra por feature
Feature
Mês
Ano
Total
Qtd
Médio
${linhas.map(l => `
${escape(l.nome)}
${brl(l.mes)}
${brl(l.ano)}
${brl(l.total)}
${l.qtd}
${brl(l.qtd ? l.total/l.qtd : 0)}
`).join('')}
TOTAL
${brl(tMes)}
${brl(tAno)}
${brl(tAll)}
${tQtd}
—
`;
}
// ----- DETALHADO (item a item) — SOMENTE O DONO -----
// Complementa o bloco agregado acima. Lista UMA linha por operação de IA,
// com cliente/processo, custo real, cobrado e lucro. Consulta direta a
// ia_custo_real (RLS só-dono garante isolamento) + ia_uso_avulso (cobrado).
// Custo real/spread NUNCA aparece pra não-dono — por isso é gated por ehOwner().
if (ehOwner()) {
try {
const det = document.createElement('div');
det.id = 'relIADetalhe';
det.style.marginTop = '18px';
el.appendChild(det);
await relRenderIADetalhe(det);
} catch (e) { console.warn('relRenderIADetalhe:', e); }
}
}
// ===== Custo de IA — DETALHADO (item a item), OWNER-ONLY =====
// Estado em memória das linhas carregadas (pra filtro/CSV sem refetch).
let _relIADetRows = [];
let _relIADetTrunc = false;
// Rótulos amigáveis das features (técnicas → legível pro dono).
const REL_IA_FEATURE_LABEL = {
'gerar-peticao': 'Petição',
'gerar-inicial': 'Petição inicial',
'analisar-integra': 'Análise da íntegra',
'classificar-doc': 'Classificação de doc',
'extrair-requisitorio': 'Requisitório',
'preparar-audiencia': 'Preparação audiência',
'pesquisa-livre': 'Pesquisa livre',
'analisar-conjunto': 'Pesquisa de documento',
'analisar-conjunto-continuar': 'Pesquisa de documento (continuação)',
};
function relIAFeatureLabel(f) { return REL_IA_FEATURE_LABEL[f] || (f || '—'); }
// ===== Dashboard de LUCRO de IA — EXCLUSIVO DO DONO (George) =====
// Receita (preço cobrado, 5×) × custo real × lucro × margem, do MÊS atual,
// quebra por feature, e série dos últimos meses (barras CSS, sem lib nova).
// Toda a margem/custo real é CONFIDENCIAL: o dado vem só de rpc_lucro_ia_mensal
// (SECURITY DEFINER, gated por app_is_owner → não-dono recebe {owner:false}),
// e esta função só é chamada quando ehOwner(). Dupla trava (front + servidor).
async function relRenderLucroIA(el) {
if (!ehOwner()) { // trava de segurança redundante (não-dono não chega aqui)
el.innerHTML = '
Indisponível.
';
return;
}
let d = null;
try {
const { data } = await sb.rpc('rpc_lucro_ia_mensal', { meses: 6 });
d = data;
} catch (e) { d = null; }
if (!d || d.owner !== true) {
el.innerHTML = '
${ic('trending-up')} Receita × Lucro · últimos meses
${serieHtml}
ReceitaLucro
Visão exclusiva do dono · receita = preço cobrado (spread embutido) · valores em R$
`;
}
async function relRenderIADetalhe(el) {
const LIMITE = 500;
// 1) ia_custo_real (custo real + cobrado). RLS só-dono.
let real = [];
try {
const { data } = await sb.from('ia_custo_real')
.select('id,feature,modelo_ia,tokens_input,tokens_output,custo_brl,preco_brl,processo_id,cliente_id,descricao,criado_em')
.order('criado_em', { ascending: false })
.limit(LIMITE);
real = Array.isArray(data) ? data : [];
} catch (e) { real = []; }
// 2) ia_uso_avulso (só o cobrado; sem custo real). Junta como fonte separada.
let avulso = [];
try {
const { data } = await sb.from('ia_uso_avulso')
.select('id,feature,modelo_ia,preco_brl,criado_em')
.order('criado_em', { ascending: false })
.limit(LIMITE);
avulso = Array.isArray(data) ? data : [];
} catch (e) { avulso = []; }
_relIADetTrunc = (real.length >= LIMITE) || (avulso.length >= LIMITE);
// ia_uso_avulso e ia_custo_real podem referir-se à MESMA operação (a edge grava
// nas duas, ligadas por uso_avulso_id). Pra não duplicar a linha no detalhado,
// ignoramos do avulso os ids já cobertos pela ia_custo_real.
const usoIdsCobertos = new Set();
// (uso_avulso_id não está no select acima — buscamos só quando há avulso)
if (avulso.length) {
try {
const { data: liga } = await sb.from('ia_custo_real')
.select('uso_avulso_id').not('uso_avulso_id', 'is', null).limit(2000);
(liga || []).forEach(r => { if (r.uso_avulso_id) usoIdsCobertos.add(r.uso_avulso_id); });
} catch (e) { /* sem ligação, mostramos tudo */ }
}
// 3) Resolve processo_id → número (uma consulta só pros ids presentes).
const procIds = [...new Set(real.map(r => r.processo_id).filter(Boolean))];
const procNum = {};
if (procIds.length) {
try {
const { data: procs } = await sb.from('processos').select('id,numero').in('id', procIds);
(procs || []).forEach(p => { procNum[p.id] = p.numero || ''; });
} catch (e) { /* sem número, usa a descrição */ }
}
// 4) Normaliza as duas fontes numa lista única.
const rows = [];
for (const r of real) {
rows.push({
data: r.criado_em,
feature: r.feature,
fonte: 'ia_custo_real',
descricao: r.descricao || '',
processoNum: r.processo_id ? (procNum[r.processo_id] || '') : '',
modelo: r.modelo_ia || '',
tin: Number(r.tokens_input || 0),
tout: Number(r.tokens_output || 0),
custoReal: (r.custo_brl == null) ? null : Number(r.custo_brl),
cobrado: (r.preco_brl == null) ? null : Number(r.preco_brl),
});
}
for (const a of avulso) {
if (usoIdsCobertos.has(a.id)) continue; // já contabilizado via ia_custo_real
rows.push({
data: a.criado_em,
feature: a.feature,
fonte: 'ia_uso_avulso',
descricao: '',
processoNum: '',
modelo: a.modelo_ia || '',
tin: null, tout: null,
custoReal: null, // avulso não guarda custo real
cobrado: (a.preco_brl == null) ? null : Number(a.preco_brl),
});
}
rows.sort((x, y) => new Date(y.data) - new Date(x.data));
_relIADetRows = rows;
// Opções do filtro de tipo (features presentes).
const featuresPresentes = [...new Set(rows.map(r => r.feature))].sort();
const optTipo = ['']
.concat(featuresPresentes.map(f => ``))
.join('');
el.innerHTML = `
Custo de IA — detalhado (item a item)
Visão exclusiva do dono. Uma linha por operação de IA (petição, pesquisa, análise...). Mostra custo real, valor cobrado e lucro.${_relIADetTrunc ? ' Exibindo as 500 operações mais recentes (há mais registros — use os filtros).' : ''}
`;
// Mostra/esconde o intervalo conforme o período.
const selPer = document.getElementById('relIADetPeriodo');
if (selPer) selPer.addEventListener('change', () => {
const box = document.getElementById('relIADetIntervalo');
if (box) box.style.display = (selPer.value === 'intervalo') ? 'flex' : 'none';
});
relIADetAplicar();
}
// Aplica os filtros (período + tipo) sobre _relIADetRows e re-renderiza a tabela.
function relIADetFiltradas() {
const per = (document.getElementById('relIADetPeriodo') || {}).value || 'tudo';
const tipo = (document.getElementById('relIADetTipo') || {}).value || '';
let de = null, ate = null;
if (per === 'mes') {
const d = new Date(); de = new Date(d.getFullYear(), d.getMonth(), 1);
} else if (per === 'ano') {
const d = new Date(); de = new Date(d.getFullYear(), 0, 1);
} else if (per === 'intervalo') {
const sDe = (document.getElementById('relIADetDe') || {}).value;
const sAte = (document.getElementById('relIADetAte') || {}).value;
if (sDe) de = new Date(sDe + 'T00:00:00');
if (sAte) ate = new Date(sAte + 'T23:59:59');
}
return _relIADetRows.filter(r => {
if (tipo && r.feature !== tipo) return false;
const dt = new Date(r.data);
if (de && dt < de) return false;
if (ate && dt > ate) return false;
return true;
});
}
function relIADetAplicar() {
const cont = document.getElementById('relIADetTabela');
if (!cont) return;
const brl = (v) => (v == null ? '—' : fmtBRL(Number(v)));
const rows = relIADetFiltradas();
let tReal = 0, tCobrado = 0, tLucro = 0, nReal = 0;
const linhas = rows.map(r => {
const lucro = (r.custoReal != null && r.cobrado != null) ? (r.cobrado - r.custoReal) : null;
if (r.custoReal != null) { tReal += r.custoReal; nReal++; }
if (r.cobrado != null) tCobrado += r.cobrado;
if (lucro != null) tLucro += lucro;
const dt = new Date(r.data);
const dataFmt = isNaN(dt) ? '—' : dt.toLocaleString('pt-BR', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
const cliProc = [r.descricao, r.processoNum].filter(Boolean).join(' · ') || '—';
const toks = (r.tin == null && r.tout == null) ? '—' : `${r.tin || 0}/${r.tout || 0}`;
const fonteTag = r.fonte === 'ia_uso_avulso' ? ' (avulso)' : '';
return `
Custos com margem comercial de segurança aplicada. Consulte o administrador para detalhes.
' : ''}
`;
}
// ----- Estilos para os gráficos de barra (adiciona uma vez) -----
if (!document.getElementById('relProdStyle')) {
const s = document.createElement('style');
s.id = 'relProdStyle';
s.textContent = '.prod-table td { vertical-align: middle; padding: 8px 10px; } .prod-bar { width: 120px; height: 18px; background: var(--border); border-radius: 4px; overflow: hidden; } .prod-bar-fill { height: 100%; border-radius: 4px; transition: width 0.3s ease; }';
document.head.appendChild(s);
}
views.inicial = async (main) => {
main.innerHTML = `
Inicial — Assistente IA
Elaboração de petição inicial, calculadoras preliminares e auto-cadastro quando o cliente é citado.
${ehOwner() ? `
${ic('dollar')} Custo real por operação (visão do dono · Anthropic Claude Sonnet 4.6):
${ic('feather')} Petição inicial: R$ 0,50 ·
📥 Cadastro citação: R$ 0,90 ·
📤 Petição intermediária (com cache): R$ 0,85 ·
🧠 Análise de íntegra: R$ 8,20 ·
🧮 Calculadoras: grátis (sem IA)
` : `
${ic('bolt')} Recursos com IA:
${ic('feather')} Petição inicial · 📥 Cadastro por citação · 📤 Petição intermediária · 🧠 Análise de íntegra ·
🧮 Calculadoras: grátis (sem IA)
`}
`;
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.
// Quem se apresenta é o USUÁRIO LOGADO (nome do próprio perfil, já tratado com
// Dr./Dra. no topo) — não um nome fixo. Sem nome no perfil, omite a apresentação.
const docsTxt = faltantes.map(t => `• ${t}`).join('\n');
const _remetenteWa = (document.getElementById('userName')?.textContent || '').trim();
const mensagemWa = `Olá ${c.nome?.split(' ')[0] || ''}!${_remetenteWa && _remetenteWa !== '—' ? ` Aqui é o(a) ${_remetenteWa}.` : ''}\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 = `
${tituloCabec}
Cliente: ${escape(c.nome)} · Área detectada: ${area}
Checklist da inicial
${checklist.map(c => `
${c.tem ? '✅' : '⬜'}
${escape(c.tipo)}
${c.tem ? 'Já cadastrado' : 'Falta — solicitar ao cliente'}
`).join('')}
${faltantes.length > 0 ? `
${ic('mobile')} Pedir documentos via WhatsApp
${!c.celular ? '
⚠ Cliente sem celular cadastrado. Atualize antes.
' : ''}
⚠ Recomendação: Não protocole a inicial antes de juntar os documentos faltantes. Risco de emenda à inicial / inépcia.
` : `
✅ Todos os documentos da área ${area} estão presentes nos anexos do cliente. Pode prosseguir com a geração da inicial.
`}
`;
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||''));
_iniCliCache = clientes; // alimenta o autocomplete sem refetch a cada tecla
await carregarAdvogados(); // advogados do tenant p/ o seletor de signatário (multi-tenant)
return `
${ic('edit')} Elaboração de petição inicial via IA
Selecione o cliente, descreva a situação, e a IA gera a peça + análise de viabilidade + lista de documentos.
`;
dd.style.display = 'block';
}
async function cliBuscaPick(fieldId, id) {
const clientes = await _cliBuscaLista();
const c = clientes.find(x => x.id === id) || (await dbGet('clientes', id).catch(() => null));
if (!c) return;
const hidden = document.getElementById(fieldId); if (hidden) hidden.value = c.id;
const inp = document.getElementById(fieldId + '__busca'); if (inp) { inp.value = c.nome || ''; inp.setAttribute('aria-expanded', 'false'); }
const dd = document.getElementById(fieldId + '__dd'); if (dd) dd.style.display = 'none';
// Dispara change no hidden pra quem quiser reagir (ex.: lógicas dependentes).
if (hidden) { try { hidden.dispatchEvent(new Event('change', { bubbles: true })); } catch (e) {} }
}
function cliBuscaKey(fieldId, e) {
const dd = document.getElementById(fieldId + '__dd');
if (!dd || dd.style.display === 'none') return;
const items = [...dd.querySelectorAll('.ini-cli-item')];
if (!items.length) return;
if (e.key === 'ArrowDown') { e.preventDefault(); _cliBuscaSel = Math.min(items.length - 1, _cliBuscaSel + 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); _cliBuscaSel = Math.max(0, _cliBuscaSel - 1); }
else if (e.key === 'Enter' && _cliBuscaSel >= 0) { e.preventDefault(); cliBuscaPick(fieldId, items[_cliBuscaSel].dataset.id); return; }
else if (e.key === 'Escape') { dd.style.display = 'none'; return; }
else return;
items.forEach((el, i) => el.classList.toggle('active', i === _cliBuscaSel));
if (items[_cliBuscaSel]) items[_cliBuscaSel].scrollIntoView({ block: 'nearest' });
}
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)';
}
}
}
// Preenche a entrevista com a tese + jurisprudência REAL da campanha Juros Zero
// (servidores/aposentados do GDF, descontos de consignado indevidos — PicPay + GDF).
// O advogado só completa os [colchetes] com os dados do caso (valores, datas).
function iniModeloJurosZero() {
const txta = document.getElementById('ini_entrevista');
if (txta && txta.value.trim() && !confirm('Substituir o texto atual da entrevista pelo modelo Juros Zero?')) return;
const modelo = `FATOS: A parte autora é servidor(a) público(a) / aposentado(a) / pensionista do Distrito Federal (GDF) e teve lançados em sua folha de pagamento descontos de empréstimo consignado vinculados à Operação "Juros Zero" (instituição PicPay), no valor de R$ [VALOR MENSAL] mensais, desde [MÊS/ANO], totalizando R$ [TOTAL JÁ DESCONTADO]. Os descontos são indevidos/abusivos: [descreva — ex.: não houve contratação regular / excedem a margem consignável legal / comprometem a subsistência].
TESE JURÍDICA (fundamentar com estes pontos e PRECEDENTES REAIS abaixo — não citar jurisprudência inexistente):
1) Relação de consumo — o CDC aplica-se às instituições financeiras (Súmula 297/STJ).
2) Limite da margem consignável (Lei nº 10.820/2003 e normas do servidor): o desconto que excede o limite legal e compromete a subsistência é abusivo; a retenção que ultrapassa ~30-35% da remuneração viola o mínimo existencial e a dignidade da pessoa humana.
3) Repetição do indébito EM DOBRO: art. 42, parágrafo único, do CDC, na forma do Tema 929/STJ (EAREsp 676.608/RS, Corte Especial, j. 21/10/2020) — cabível quando a cobrança indevida contraria a boa-fé objetiva, INDEPENDENTEMENTE de má-fé/dolo (responsabilidade objetiva); modulação para cobranças posteriores à publicação do acórdão (2021).
4) Dano moral: descontos indevidos de consignado que comprometem a subsistência configuram dano moral (não mero aborrecimento), inclusive presumido, conforme jurisprudência consolidada do TJDFT.
5) POLO PASSIVO: PicPay Instituição de Pagamento S.A. E Distrito Federal (GDF), em responsabilidade SOLIDÁRIA — responsabilidade objetiva do Estado (art. 37, §6º, da CF) por omissão no dever de fiscalizar os descontos consignados de seus servidores.
6) Justiça gratuita: parte autora hipossuficiente (art. 98 do CPC).
PEDIDOS: (a) tutela de urgência para CESSAÇÃO IMEDIATA dos descontos; (b) declaração de inexigibilidade/abusividade dos descontos que excedam o limite legal; (c) restituição EM DOBRO dos valores descontados indevidamente; (d) indenização por danos morais; (e) concessão da justiça gratuita; (f) condenação dos réus, solidariamente, em custas e honorários.
FORO: Juizado Especial Cível do TJDFT — Brasília/DF.`;
if (txta) { txta.value = modelo; txta.focus(); }
// Signatário sugerido do modelo Juros Zero: busca SÓ dentro do roster do
// próprio escritório (ADVOGADOS_INDIVIDUAIS é tenant-only). Se este escritório
// não tiver ninguém com esse nome, não mexe no seletor — nunca traz alguém de
// fora. (Juros Zero é operado pelo escritório do dono da plataforma.)
const adv = document.getElementById('ini_advogado');
if (adv) { const _al = (ADVOGADOS_INDIVIDUAIS || []).find(a => /alessandro/i.test(a.nome || '')); if (_al) adv.value = _al.id; }
const tip = document.getElementById('ini_tipo_acao');
if (tip) { for (const o of tip.options) { if (/repeti|ind[eé]bito|consumidor|revisional|c[ií]vel/i.test(o.value)) { tip.value = o.value; break; } } }
if (typeof toast === 'function') toast('Modelo Juros Zero inserido — preencha os [colchetes] com os dados do cliente', 'success');
}
// ===== Importar anexos do cliente pra alimentar a elaboração da inicial =====
// Lista os documentos do cliente selecionado; o advogado marca quais importar (todos
// ou alguns). Pra cada um: ÁUDIO -> transcreve (edge transcrever-audio); PDF/IMAGEM ->
// OCR (edge classificar-doc, mesmo caminho do gerar-petição); TXT -> lê direto. O texto
// extraído é anexado ao textarea da entrevista (#ini_entrevista), que o gerar-inicial
// já consome — sem mexer na edge. Erro por-anexo não aborta os demais.
let _iniAnexosCli = [];
function _iniAnexoEhAudio(d){ return /^audio\//.test(d.mime_type||'') || /\.(mp3|m4a|aac|ogg|opus|wav|webm|amr|mpeg)$/i.test(d.nome||''); }
function _iniAnexoEhDoc(d){ return (d.mime_type||'')==='application/pdf' || /^image\//.test(d.mime_type||'') || /\.(pdf|png|jpe?g|webp|gif)$/i.test(d.nome||''); }
function _iniAnexoEhTxt(d){ return (d.mime_type||'').startsWith('text/') || /\.txt$/i.test(d.nome||''); }
async function iniImportarAnexos() {
const clienteId = document.getElementById('ini_cliente')?.value;
if (!clienteId) { toast('Selecione o cliente primeiro (campo Cliente).', 'danger'); return; }
let docs = [];
try {
const { data, error } = await sb.from('documentos')
.select('id,nome,tipo,mime_type,storage_path,tamanho_bytes,created_at')
.eq('cliente_id', clienteId).order('created_at', { ascending: false });
if (error) throw error;
docs = (data || []).filter(d => d.storage_path);
} catch (e) { toast('Erro ao listar anexos: ' + (e.message || e), 'danger'); return; }
if (!docs.length) { toast('Este cliente não tem anexos. Anexe na ficha do cliente (Dados Pessoais → Anexos).', 'warning'); return; }
_iniAnexosCli = docs;
const linhas = docs.map((d, i) => {
const kind = _iniAnexoEhAudio(d) ? '🎙️ áudio' : (_iniAnexoEhDoc(d) ? '📄 documento' : (_iniAnexoEhTxt(d) ? '📝 texto' : '⚠ tipo não suportado'));
const dt = d.created_at ? new Date(d.created_at).toLocaleDateString('pt-BR') : '';
const kb = d.tamanho_bytes ? (d.tamanho_bytes/1024).toFixed(0)+' KB' : '';
const sup = _iniAnexoEhAudio(d) || _iniAnexoEhDoc(d) || _iniAnexoEhTxt(d);
return ``;
}).join('');
const body = `
Marque os anexos pra IA analisar na elaboração da inicial. Áudios são transcritos; PDFs/imagens lidos por OCR. O conteúdo entra na entrevista (revise antes de gerar).
${linhas}
`;
openModal('📎 Importar anexos do cliente', body,
`
`);
}
async function iniImportarAnexosConfirmar() {
const idxs = [...document.querySelectorAll('.ini-anexo-chk')].filter(c=>c.checked && !c.disabled).map(c=>parseInt(c.dataset.idx,10));
if (!idxs.length) { toast('Marque ao menos um anexo.', 'danger'); return; }
const prog = document.getElementById('ini_anexo_prog');
const btn = document.getElementById('ini_anexo_btn');
if (btn) { btn.disabled = true; btn.textContent = 'Importando…'; }
const CAP = 8000;
let blocos = [], ok = 0, falhas = 0;
let token = '';
try { const { data: { session } } = await sb.auth.getSession(); token = session?.access_token || ''; } catch (e) {}
for (let n = 0; n < idxs.length; n++) {
const d = _iniAnexosCli[idxs[n]];
if (!d) continue;
const nome = d.nome || 'documento';
if (prog) prog.textContent = `(${n+1}/${idxs.length}) ${_iniAnexoEhAudio(d)?'Transcrevendo áudio':'Lendo'}: ${nome}…`;
try {
let texto = '', kind = 'documento';
if (_iniAnexoEhAudio(d)) {
kind = 'transcrição de áudio';
const r = await fetch(`${SUPABASE_URL}/functions/v1/transcrever-audio`, {
method:'POST', headers:{'Content-Type':'application/json','Authorization':'Bearer '+token},
body: JSON.stringify({ storage_path: d.storage_path }) });
const j = await r.json(); if (!r.ok) throw new Error(j.error||'falha na transcrição');
texto = String(j.texto||'').trim();
} else if (_iniAnexoEhDoc(d)) {
const r = await fetch(`${SUPABASE_URL}/functions/v1/classificar-doc`, {
method:'POST', headers:{'Content-Type':'application/json','Authorization':'Bearer '+token},
body: JSON.stringify({ storage_path: d.storage_path, mime_type: d.mime_type||null }) });
const j = await r.json(); if (!r.ok) throw new Error(j.error||'falha no OCR');
texto = String(j.transcricao||'').trim();
const dx = j.dados_extraidos||{};
const est = ['nome','cpf','cnpj','endereco','bairro','cidade','uf','cep'].map(k=>dx[k]?`${k}: ${dx[k]}`:'').filter(Boolean).join(' | ');
if (est) texto = (texto?texto+'\n\n':'')+'DADOS EXTRAÍDOS: '+est;
} else if (_iniAnexoEhTxt(d)) {
kind = 'texto';
const { data: blob, error } = await sb.storage.from(ANEXOS_BUCKET).download(d.storage_path);
if (error) throw error;
texto = (await blob.text()).trim();
} else { falhas++; continue; }
if (!texto) { falhas++; continue; }
blocos.push(`\n\n=== ANEXO: ${nome} (${kind}) ===\n${texto.slice(0, CAP)}`);
ok++;
} catch (e) { falhas++; }
}
if (ok) {
const ta = document.getElementById('ini_entrevista');
if (ta) { ta.value = (ta.value||'').trimEnd() + blocos.join(''); try { ta.dispatchEvent(new Event('input')); } catch(e){} }
}
closeModal();
const totalChars = blocos.join('').length;
toast(`${ok} anexo(s) importado(s)${falhas?` · ${falhas} não lido(s)`:''} (~${Math.round(totalChars/1000)} mil caracteres). Revise a entrevista antes de gerar.`, ok ? 'success' : 'danger');
}
/* 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 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; }
// Sem advogado cadastrado no escritório a inicial sairia sem signatário (ou,
// antes do fix de 27/07, com o nome/OAB de advogado de OUTRO escritório).
// Barra aqui e manda cadastrar — peça sem OAB certa não pode ser gerada.
if (advogadosVazios()) { toast(ADVOGADOS_VAZIOS_MSG, 'danger'); return; }
if (await _iaGate()) return; // teto de custo de IA (trial)
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; }
// Comarca/foro do protocolo = cidade do CADASTRO do cliente (é onde a ação será
// distribuída). Sem campo manual — o cadastro do cliente é a fonte de verdade.
const comarca = [cliente.cidade, cliente.uf].filter(Boolean).join(' / ') || 'São Paulo';
const _advSel = ADVOGADOS_INDIVIDUAIS.find(a => a.id === advogadoKey) || ADVOGADOS_INDIVIDUAIS[0] || {};
const adv = { nome: _advSel.nome || '', oab: (_advSel.oabs || []).join(' e ') };
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 = '
'+ic('feather')+' 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: ${fmtBRL(data.preco)})`, 'warning');
return;
}
_iniEntrevistaAtual = { id: entrevistaId, ...data, cliente, tipo_acao };
_iniMostrarResultado(data);
toast(`✓ Inicial gerada — custo ${fmtBRL(data.preco)}`, 'success');
} catch (e) {
console.error(e);
status.innerHTML = `
Erro: ${escape(e.message || String(e))}
`;
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 = `
⚠ Faltam dados pra elaborar a inicial
${escape(data.motivo || 'A IA precisa de mais informações antes de redigir a peça.')}
${ic('edit')} Perguntas que você precisa fazer ao cliente:
${perguntas.map(p => `
${escape(p)}
`).join('') || '
—
'}
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 ${fmtBRL(data.preco)} (não gerou peça).
${w.ativo?'ATIVO':'DESATIVADO'}criado em ${fmtDate(w.created_at)}
`).join('');
const exemploCurl = whs && whs.length ? `curl -X POST '${fnUrl}' \\
-H 'Content-Type: application/json' \\
-d '{
"api_key": "${escape(whs[0].api_key)}",
"titulo": "João da Silva",
"telefone": "11999999999",
"email": "joao@exemplo.com",
"origem": "Meta Ads",
"descricao":"Quer abrir uma ação trabalhista contra ex-empregador",
"valor_estimado": 5000
}'` : '';
kbModal('🔗 Importar leads via webhook', `
Webhook genérico que aceita POST de qualquer fonte (Meta Ads via Zapier, iFood, formulário do site, n8n, etc) e cria o lead como cartão na 1ª coluna deste quadro (Prospect) com checklist de documentação obrigatória já pré-criado.
URL do endpoint:
Chaves de API deste quadro
${lista || '
Nenhuma chave gerada. Crie a primeira abaixo.
'}
${whs && whs.length ? `
${ic('clipboardList')} Exemplo de uso (curl)
${escape(exemploCurl)}
Campos:api_key e titulo são obrigatórios. telefone, email, origem, descricao, valor_estimado e responsavel são opcionais.
` : ''}
`, [
{ label: '+ Nova chave', class: 'btn-primary', onClick: () => kbCriarWebhook() },
{ label: 'Fechar', class: 'btn-secondary', onClick: closeModal },
]);
} catch (err) { toast('Erro: ' + err.message, 'danger'); }
}
async function kbCriarWebhook() {
try {
await sb.from('kanban_webhooks').insert({ board_id: _kbBoardAtual.id });
kbWebhooks();
} catch (err) { toast('Erro: ' + err.message, 'danger'); }
}
async function kbToggleWebhook(id, ativo) {
try {
await sb.from('kanban_webhooks').update({ ativo }).eq('id', id);
kbWebhooks();
} catch (err) { toast('Erro: ' + err.message, 'danger'); }
}
async function kbDelWebhook(id) {
if (!confirm('Excluir esta chave de API? Quem estiver usando ela perderá acesso ao webhook.')) return;
try {
await sb.from('kanban_webhooks').delete().eq('id', id);
kbWebhooks();
} catch (err) { toast('Erro: ' + err.message, 'danger'); }
}
// ---- Badge de vencidos na navbar (visível de qualquer tela) ----
async function kbAtualizarBadgeVencidos() {
const btn = document.querySelector('.nav-btn[data-view="prospeccao"]');
if (!btn) return;
// remove badge antigo
const oldBadge = btn.querySelector('.kb-nav-badge');
if (oldBadge) oldBadge.remove();
try {
const hoje = new Date().toISOString().slice(0, 10);
const { data, error } = await sb.from('kanban_cards')
.select('id, vencimento, convertido', { count: 'exact' })
.lt('vencimento', hoje)
.eq('convertido', false);
if (error || !data || !data.length) return;
const span = document.createElement('span');
span.className = 'kb-nav-badge';
span.style.cssText = 'display:inline-block; background:#dc2626; color:white; border-radius:999px; padding:1px 8px; font-size:11px; margin-left:4px; font-weight:700';
span.textContent = '⏰ ' + data.length;
span.title = `${data.length} cartão(ões) vencido(s)`;
btn.appendChild(span);
} catch { /* silencioso — badge é cosmético */ }
}
// ---- Helpers ----
function fmtMoney(v) {
return 'R$ ' + Number(v || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function fmtDateTime(iso) {
if (!iso) return '';
try {
const d = new Date(iso);
const dia = String(d.getDate()).padStart(2,'0');
const mes = String(d.getMonth()+1).padStart(2,'0');
const ano = d.getFullYear();
const hh = String(d.getHours()).padStart(2,'0');
const mm = String(d.getMinutes()).padStart(2,'0');
return `${dia}/${mes}/${ano} ${hh}:${mm}`;
} catch { return iso; }
}
/* --------- Bootstrap --------- */
(async () => {
await openLocalDB();
const { data: { session } } = await sb.auth.getSession();
if (!session) { showLogin(); return; }
updateUserUI(session.user);
hideLogin();
// Delegação: aplica o modelo visual escolhido e aquece os caches (meu uid + membros),
// que o selo de delegação lê de forma síncrona ao renderizar o calendário.
_delegModeloAplicar();
try { await meuUserId(); } catch (e) {}
try { carregarMembrosEscritorio(); } catch (e) {}
// Pop-up de tarefas/prazos ATRASADOS do usuário (1x por dia). Atrasado por um
// instante pra não competir com o render inicial.
setTimeout(() => { try { verificarTarefasAtrasadas(); } catch (_) {} }, 1800);
// Pop-up de CIÊNCIA OBRIGATÓRIA: tarefas delegadas a mim que exigem ciência e
// que eu ainda não confirmei que vi. Reaparece a cada acesso até dar ciência.
setTimeout(() => { try { checarCienciasObrigatorias(); } catch (_) {} }, 2600);
// Badge de publicações NOVAS no menu (pílula) — carrega junto com o app.
setTimeout(() => { try { atualizarBadgePublicacoes(); } catch (_) {} }, 1500);
// Onboarding: convida o admin de escritório novo a escolher seus tribunais.
setTimeout(() => { try { nudgeTribunaisOnboarding(); } catch (_) {} }, 2400);
setTimeout(() => { try { nudgeGestorArquivos(); } catch (_) {} }, 7000);
// Tour guiado de primeira vez (só se onboarding_concluido=false no escritório)
setTimeout(() => { try { verificarOnboarding(); } catch (_) {} }, 3200);
// Deep-link (?cliente=UUID&tab=processos&processo=UUID)
// do cliente na aba indicada e destaca o processo. Usado pelo "Abrir processo
// no app" das Publicações, que abre em NOVA ABA do navegador.
try {
const params = new URLSearchParams(location.search);
const cliKey = params.get('cliente');
if (cliKey) {
_highlightProcessoId = params.get('processo') || null;
await abrirClienteEm(cliKey, params.get('tab') || 'processos');
return;
}
// Deep-link de view (?view=protocolo) — abre direto a aba indicada numa nova
// aba do navegador (usado pelo "Enviar p/ protocolo", que mantém a peça aberta).
const viewKey = params.get('view');
const navAlvo = viewKey && document.querySelector(`.nav-btn[data-view="${viewKey}"]`);
if (navAlvo) {
navAlvo.click(); // marca o botão como .active E renderiza (renderView sozinho não marca active — Realtime/polling do Protocolo dependem disso)
return;
}
} catch (e) { console.warn('deep-link falhou:', e); }
renderView('dashboard');
})();
/* ================= FAB de suporte (speed-dial) ================= */
function supportFabToggle() {
const wrap = document.getElementById('supportFabWrap');
const fab = document.getElementById('supportFab');
const aberto = wrap.classList.toggle('open');
fab.classList.toggle('open', aberto);
fab.setAttribute('aria-expanded', aberto ? 'true' : 'false');
if (aberto) {
// ajusta o item WhatsApp conforme a constante estar configurada ou não
const wa = document.getElementById('supportActionWA');
const semNumero = !IURIA_SUPORTE_WHATSAPP || /X/i.test(IURIA_SUPORTE_WHATSAPP);
if (wa) {
wa.toggleAttribute('disabled', semNumero);
wa.setAttribute('aria-disabled', semNumero ? 'true' : 'false');
wa.title = semNumero ? 'Configurar número do suporte' : 'Abrir conversa no WhatsApp';
}
}
}
function supportFabClose() {
const wrap = document.getElementById('supportFabWrap');
const fab = document.getElementById('supportFab');
wrap.classList.remove('open');
fab.classList.remove('open');
fab.setAttribute('aria-expanded', 'false');
}
function supportAbrirWhatsApp() {
const semNumero = !IURIA_SUPORTE_WHATSAPP || /X/i.test(IURIA_SUPORTE_WHATSAPP);
if (semNumero) { toast('Suporte por WhatsApp ainda não configurado.', 'warning'); return; }
window.open('https://wa.me/' + IURIA_SUPORTE_WHATSAPP, '_blank', 'noopener');
supportFabClose();
}
function supportAbrirIA() {
supportFabClose();
const p = document.getElementById('suporteIAPanel');
p.classList.add('open');
const inp = document.getElementById('suporteIAInput');
if (inp) inp.focus();
}
function supportFecharIA() {
document.getElementById('suporteIAPanel').classList.remove('open');
}
// Histórico acumulado da conversa do Suporte com IA (sessão atual).
let suporteIAHistorico = [];
let suporteIAEnviando = false;
async function suporteIAEnviar() {
const inp = document.getElementById('suporteIAInput');
const thread = document.getElementById('suporteIAThread');
const btn = document.getElementById('suporteIABtn');
if (suporteIAEnviando) return;
const pergunta = (inp.value || '').trim();
if (!pergunta) { inp.focus(); return; }
if (pergunta.length > 1500) { toast('Mensagem muito longa (máx. 1500 caracteres).', 'warning'); return; }
// mensagem do usuário
const u = document.createElement('div');
u.className = 'sp-msg';
u.style.background = 'var(--primary-light)';
u.textContent = pergunta;
thread.appendChild(u);
inp.value = '';
thread.scrollTop = thread.scrollHeight;
// placeholder "digitando"
const r = document.createElement('div');
r.className = 'sp-msg';
r.textContent = 'Consultando…';
thread.appendChild(r);
thread.scrollTop = thread.scrollHeight;
suporteIAEnviando = true;
if (btn) btn.disabled = true;
try {
const { data: { session } } = await sb.auth.getSession();
if (!session?.access_token) throw new Error('Sessão expirada — refaça login');
const resp = await fetch(`${SUPABASE_URL}/functions/v1/suporte-ia`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + session.access_token },
body: JSON.stringify({ pergunta, historico: suporteIAHistorico.slice(-12) }),
});
const json = await resp.json().catch(() => ({}));
if (!resp.ok || !json || json.ok === false || !json.resposta) {
throw new Error((json && json.error) || ('Erro ' + resp.status));
}
const resposta = String(json.resposta);
r.innerHTML = siaMarkdownToHtml(resposta);
// acumula no histórico só após sucesso
suporteIAHistorico.push({ role: 'user', conteudo: pergunta });
suporteIAHistorico.push({ role: 'assistant', conteudo: resposta });
} catch (e) {
r.textContent = 'Não consegui responder agora. Tente novamente ou use o WhatsApp do suporte humano.';
console.warn('[suporte-ia]', e && e.message ? e.message : e);
} finally {
suporteIAEnviando = false;
if (btn) btn.disabled = false;
thread.scrollTop = thread.scrollHeight;
}
}
// Fecha o speed-dial / painel ao clicar fora ou apertar ESC
document.addEventListener('click', function (ev) {
const wrap = document.getElementById('supportFabWrap');
const panel = document.getElementById('suporteIAPanel');
if (wrap && wrap.classList.contains('open') && !wrap.contains(ev.target)) supportFabClose();
if (panel && panel.classList.contains('open') && !panel.contains(ev.target) &&
!(wrap && wrap.contains(ev.target))) panel.classList.remove('open');
});
document.addEventListener('keydown', function (ev) {
if (ev.key !== 'Escape') return;
supportFabClose();
const panel = document.getElementById('suporteIAPanel');
if (panel) panel.classList.remove('open');
});
/* ====================================================================
* PWA — instalação + Service Worker + Push notifications
* --------------------------------------------------------------------
* - Registra o SW (/sw.js). O SW é network-first p/ o documento, então
* deploy novo sempre aparece; nunca prende em versão velha.
* - Captura beforeinstallprompt p/ botão "Instalar app" discreto.
* - iOS (sem prompt): mostra instruções "Adicionar à Tela de Início".
* - Push: chave VAPID pública embutida; inscrição salva em
* push_subscriptions (RLS por escritório). Ativar/desativar em
* Configurações — nunca popup automático no load.
* ==================================================================== */
// Chave VAPID PÚBLICA (par gerado p/ o IURIA; a privada é secret no
// Supabase: VAPID_PRIVATE_KEY). Pode ficar exposta no front — é pública.
const VAPID_PUBLIC_KEY = 'BNsfvxXuOZTSElm3bIIcbM_dYLC4SOZrDj3nO4xBhVeQYINCsRC2bSo2fSveqeLTU5Ax6ETUz_k6GsUK63shaRU';
let _deferredInstallPrompt = null;
let _swRegistration = null;
function _urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
function _isStandalonePWA() {
return (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) ||
window.navigator.standalone === true;
}
function _isIOS() {
return /iphone|ipad|ipod/i.test(navigator.userAgent) && !window.MSStream;
}
// Registra o Service Worker (não bloqueia o boot).
async function registrarServiceWorker() {
if (!('serviceWorker' in navigator)) return;
// Só registra em https (produção) ou localhost; em file:// não funciona.
if (location.protocol !== 'https:' && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1') return;
try {
_swRegistration = await navigator.serviceWorker.register('/sw.js', { scope: '/' });
// Se houver um SW novo esperando, manda assumir (não prende versão velha).
if (_swRegistration.waiting) _swRegistration.waiting.postMessage('SKIP_WAITING');
_swRegistration.addEventListener('updatefound', () => {
const sw = _swRegistration.installing;
if (!sw) return;
sw.addEventListener('statechange', () => {
if (sw.state === 'installed' && _swRegistration.waiting) {
_swRegistration.waiting.postMessage('SKIP_WAITING');
}
});
});
} catch (e) {
console.warn('[pwa] SW falhou:', e && e.message ? e.message : e);
}
}
// Botão "Instalar app" discreto: aparece só quando dá pra instalar.
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
_deferredInstallPrompt = e;
_atualizarBotaoInstalar();
});
window.addEventListener('appinstalled', () => {
_deferredInstallPrompt = null;
_atualizarBotaoInstalar();
try { toast('IURIA instalado.', 'success'); } catch {}
});
function _atualizarBotaoInstalar() {
const btn = document.getElementById('btnInstalarPWA');
if (!btn) return;
// Mostra se: há prompt nativo (Android/Chrome desktop) OU é iOS fora do app.
const podeInstalar = !!_deferredInstallPrompt || (_isIOS() && !_isStandalonePWA());
btn.style.display = (podeInstalar && !_isStandalonePWA()) ? '' : 'none';
}
async function instalarPWA() {
// iOS: não há prompt programático → instruções.
if (!_deferredInstallPrompt) {
if (_isIOS()) {
try {
(typeof modalInfo === 'function')
? modalInfo('Instalar o IURIA', 'No iPhone/iPad: toque no botão Compartilhar (quadrado com seta) na barra do Safari e escolha "Adicionar à Tela de Início".')
: alert('No iPhone/iPad: toque em Compartilhar no Safari e escolha "Adicionar à Tela de Início".');
} catch {
alert('No iPhone/iPad: toque em Compartilhar no Safari e escolha "Adicionar à Tela de Início".');
}
} else {
try { toast('Use o menu do navegador → "Instalar app".', 'info'); } catch {}
}
return;
}
_deferredInstallPrompt.prompt();
try { await _deferredInstallPrompt.userChoice; } catch {}
_deferredInstallPrompt = null;
_atualizarBotaoInstalar();
}
/* --------- Push: estado do botão em Configurações --------- */
function _pushSuportado() {
return ('serviceWorker' in navigator) && ('PushManager' in window) && ('Notification' in window);
}
async function pushRenderEstado() {
const wrap = document.getElementById('pushConfigWrap');
if (!wrap) return;
if (!_pushSuportado()) {
wrap.innerHTML = '
Seu navegador não suporta notificações push.' +
(_isIOS() ? ' No iPhone/iPad, instale o IURIA na Tela de Início primeiro (iOS 16.4+).' : '') + '
';
const on = document.getElementById('btnPushOn');
const off = document.getElementById('btnPushOff');
const teste = document.getElementById('btnPushTeste');
if (on) on.onclick = pushAtivar;
if (off) off.onclick = pushDesativar;
if (teste) teste.onclick = pushEnviarTeste;
}
// Dispara um push de teste pra própria conta (chama a edge enviar-push com o
// JWT do usuário; a edge só envia pra si mesmo nesse modo).
async function pushEnviarTeste() {
const btn = document.getElementById('btnPushTeste');
if (btn) { btn.disabled = true; btn.textContent = 'Enviando…'; }
try {
const { data: { session } } = await sb.auth.getSession();
if (!session) { toast('Faça login para testar.', 'warning'); return; }
const resp = await fetch(SUPABASE_URL + '/functions/v1/enviar-push', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + session.access_token, 'apikey': SUPABASE_KEY },
body: JSON.stringify({ titulo: 'IURIA', corpo: 'Notificação de teste — está tudo funcionando.', url: '/' }),
});
const r = await resp.json().catch(() => ({}));
if (resp.ok && r.ok) {
toast(r.enviados ? ('Push de teste enviado (' + r.enviados + ').') : 'Nenhum dispositivo recebeu (reative as notificações).', r.enviados ? 'success' : 'warning');
} else {
toast('Falha no envio: ' + (r.detalhe || r.error || resp.status), 'danger');
}
} catch (e) {
console.warn('[push] teste falhou:', e && e.message ? e.message : e);
toast('Não consegui enviar o push de teste.', 'danger');
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Enviar teste'; }
}
}
async function pushAtivar() {
const btn = document.getElementById('btnPushOn');
if (btn) { btn.disabled = true; btn.textContent = 'Aguardando permissão…'; }
try {
if (!_pushSuportado()) { toast('Navegador sem suporte a push.', 'warning'); return; }
const perm = await Notification.requestPermission();
if (perm !== 'granted') { toast('Permissão de notificação não concedida.', 'warning'); return; }
const reg = _swRegistration || await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: _urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
}
const { data: { user } } = await sb.auth.getUser();
if (!user) { toast('Faça login para ativar notificações.', 'warning'); return; }
const j = sub.toJSON();
// Upsert por endpoint (idempotente): reativa se já existia.
const { error } = await sb.from('push_subscriptions').upsert({
user_id: user.id,
endpoint: j.endpoint,
subscription: j,
user_agent: (navigator.userAgent || '').slice(0, 300),
ativo: true,
}, { onConflict: 'endpoint' });
if (error) throw error;
toast('Notificações ativadas.', 'success');
} catch (e) {
console.warn('[push] ativar falhou:', e && e.message ? e.message : e);
toast('Não consegui ativar as notificações.', 'danger');
} finally {
pushRenderEstado();
}
}
async function pushDesativar() {
const btn = document.getElementById('btnPushOff');
if (btn) { btn.disabled = true; btn.textContent = 'Desativando…'; }
try {
const reg = _swRegistration || await navigator.serviceWorker.getRegistration();
const sub = reg && await reg.pushManager.getSubscription();
if (sub) {
const ep = sub.endpoint;
try { await sub.unsubscribe(); } catch {}
try { await sb.from('push_subscriptions').update({ ativo: false }).eq('endpoint', ep); } catch {}
}
toast('Notificações desativadas neste dispositivo.', 'info');
} catch (e) {
console.warn('[push] desativar falhou:', e && e.message ? e.message : e);
} finally {
pushRenderEstado();
}
}
// Liga o registro do SW e o estado do botão de instalar no load.
registrarServiceWorker();
window.addEventListener('load', () => { _atualizarBotaoInstalar(); });
// ===== ANTI-CORTE do nav (27/07): mede de verdade. Se os itens não cabem na
// largura da tela (notebook 1366/1280, zoom 125%), liga a quebra de linha —
// nada é engolido pelo overflow-x:hidden do body. Re-mede no resize e quando
// o conteúdo do nav muda (troca de view, badges).
function navAjustarScroll() {
try {
const nv = document.querySelector('nav.mainnav');
if (!nv) return;
nv.classList.remove('nav-wrap'); // mede no estado natural
if (nv.scrollWidth > nv.clientWidth + 2) nv.classList.add('nav-wrap');
} catch (e) {}
}
window.navAjustarScroll = navAjustarScroll;
(function () {
let t = null;
const agenda = () => { clearTimeout(t); t = setTimeout(navAjustarScroll, 120); };
window.addEventListener('resize', agenda);
window.addEventListener('load', agenda);
try {
const nv = document.querySelector('nav.mainnav');
if (nv && window.MutationObserver) new MutationObserver(agenda).observe(nv, { childList: true, subtree: true, characterData: true });
} catch (e) {}
agenda();
})();
// Carrega o logotipo + branding do escritório (se houver) para usar nas peças
addEventListener('load', async () => {
try {
const u = (await sb.auth.getUser()).data?.user;
if (!u) return;
const perfil = (await sb.from('perfis_usuario').select('escritorio_id, logo_url').eq('user_id', u.id).maybeSingle()).data;
if (!perfil?.escritorio_id) return;
if (perfil.logo_url) _logoEscritorioUrl = perfil.logo_url; // logo POR ADVOGADO (perfil)
const esc = (await sb.from('escritorios').select('branding_nome, branding_endereco, branding_contato, branding_cep').eq('id', perfil.escritorio_id).maybeSingle()).data;
if (esc) _brandingEscritorio = { nome: esc.branding_nome, endereco: esc.branding_endereco, contato: esc.branding_contato };
// Semeia o clima do painel pelo CEP do escritório quando o aparelho ainda não tem local.
try { if (esc && esc.branding_cep && !_dashLocSalva()) { _dashResolverCEP(esc.branding_cep).then(() => { try { dashHeroRender(); } catch (e) {} }).catch(() => {}); } } catch (e) {}
} catch (e) { /* silencioso */ }
});
// ===================================================================
// CONSENTIMENTO DE COOKIES (LGPD — Lei 13.709/2018, art. 7º/8º;
// Guia de Cookies da ANPD, 2022). Banner na 1ª visita: aceitar todos,
// recusar não essenciais, ou personalizar (aceitar em parte). Cookies
// necessários (login/sessão) são informados mas dispensam consentimento.
// A escolha fica no aparelho (localStorage) com data e versão; pode ser
// reaberta pelo link "Preferências de cookies" (rodapé/Privacidade).
// ===================================================================
const COOKIE_CONSENT_KEY = 'iuria_cookie_consent';
const COOKIE_CONSENT_VERSAO = '2026-07-26';
function _cookieConsentGet() { try { return JSON.parse(localStorage.getItem(COOKIE_CONSENT_KEY) || 'null'); } catch (e) { return null; } }
function _cookieAplicar(reg) {
// Ponto único p/ ligar/desligar scripts não essenciais conforme o consentimento.
// Hoje o IURIA usa apenas cookies necessários; analytics/marketing entrariam aqui.
window.__cookieConsent = reg || null;
}
function _cookieConsentSalvar(cats) {
const reg = { versao: COOKIE_CONSENT_VERSAO, em: new Date().toISOString(), categorias: Object.assign({ necessarios: true }, cats) };
try { localStorage.setItem(COOKIE_CONSENT_KEY, JSON.stringify(reg)); } catch (e) {}
_cookieAplicar(reg);
const el = document.getElementById('cookieConsentBar'); if (el) el.remove();
}
function cookieAceitarTodos() { _cookieConsentSalvar({ preferencias: true, analiticos: true }); try { toast('Preferências de cookies salvas.', 'success'); } catch (e) {} }
function cookieRecusarNaoEssenciais() { _cookieConsentSalvar({ preferencias: false, analiticos: false }); try { toast('Só cookies essenciais serão usados.', 'success'); } catch (e) {} }
function cookieSalvarEscolhas() {
const p = document.getElementById('ckPref'), a = document.getElementById('ckAnalise');
_cookieConsentSalvar({ preferencias: !!(p && p.checked), analiticos: !!(a && a.checked) });
try { toast('Preferências de cookies salvas.', 'success'); } catch (e) {}
}
function _cookiePersonalizarToggle() {
const box = document.getElementById('cookieCustom'), sv = document.getElementById('ckSalvar');
if (!box) return;
const abrir = box.style.display === 'none';
box.style.display = abrir ? 'block' : 'none';
if (sv) sv.style.display = abrir ? '' : 'none';
}
function cookieAbrirPreferencias() { const ex = document.getElementById('cookieConsentBar'); if (ex) ex.remove(); _cookieBannerMostrar(true); }
function _cookieBannerMostrar(forcar) {
if (!forcar && _cookieConsentGet()) return; // já decidiu
if (document.getElementById('cookieConsentBar')) return; // já visível
const reg = _cookieConsentGet() || { categorias: {} };
const bar = document.createElement('div');
bar.id = 'cookieConsentBar';
bar.setAttribute('role', 'dialog');
bar.setAttribute('aria-label', 'Aviso de cookies');
bar.style.cssText = 'position:fixed;left:0;right:0;bottom:0;z-index:99999;padding:14px;display:flex;justify-content:center;pointer-events:none';
bar.innerHTML = `
🍪 Cookies e privacidade
Usamos cookies necessários para você entrar e o sistema funcionar (sessão e login) e, só com o seu consentimento, cookies de preferências e analíticos para melhorar o IURIA. Você pode aceitar todos, recusar os não essenciais ou escolher em parte. Saiba mais na Política de Privacidade (LGPD, art. 7º e 8º).