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(', ')) : '';
// ANTI-DUPLICATA (fix): antes de inserir, verifica se JÁ EXISTE item pendente do
// MESMO processo na fila de protocolo (erro, reprovado ou em processamento).
// Se existir, RETOMA a linha existente (update) em vez de criar uma nova — o
// mesmo processo não pode aparecer 2x na fila (ex.: antiga em "erro" + nova em
// "aguardando assinatura"). Só itens NÃO concluídos são retomados; peça nova
// depois de protocolado/arquivado é legítima e segue criando linha nova.
const { data: jaNaFila } = await sb.from('protocolo_fila')
.select('id,status,arquivo_path')
.eq('processo_id', processoId)
.eq('tipo', 'protocolo')
.in('status', ['erro', 'reprovado', 'aprovado', 'aguardando_pdf', 'aguardando_protocolo', 'aguardando_assinatura']);
const itemAntigo = (jaNaFila && jaNaFila.length) ? jaNaFila[0] : null;
const camposFila = {
numero_incidente: numeroIncidente, // ← protocolar NO incidente, não no processo-mãe
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,
observacao: (((comando ? 'Comando IA: ' + comando : '') + anexosNota).trim()).slice(0, 500) || null,
};
let errFila = null;
let retomouItem = false;
if (itemAntigo) {
// Retoma o item existente: volta pro fluxo do watcher (renderiza PDF →
// 'aprovado' → autopet) com a peça NOVA. Reseta o resultado do ciclo
// anterior (flags de erro/login/preparo) e manda o watcher rodar já.
const { error: errUpd } = await sb.from('protocolo_fila')
.update({ ...camposFila, status: 'aguardando_pdf', resultado: { retry_login: true, retomado_em: new Date().toISOString() } })
.eq('id', itemAntigo.id);
if (errUpd) { await sb.storage.from(ANEXOS_BUCKET).remove([storagePath]); throw new Error('Retomada falhou: ' + errUpd.message); }
// Remove o HTML antigo substituído (fire-and-forget; não quebra o fluxo)
if (itemAntigo.arquivo_path && itemAntigo.arquivo_path !== storagePath) {
sb.storage.from(ANEXOS_BUCKET).remove([itemAntigo.arquivo_path]).catch(() => {});
}
errFila = null;
retomouItem = true;
} else {
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: camposFila.polo,
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: camposFila.observacao,
}]);
errFila = errIns;
}
if (errFila) { await sb.storage.from(ANEXOS_BUCKET).remove([storagePath]); throw new Error(errFila.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(retomouItem ? 'Retomei o item que já estava na fila (sem duplicar) com a peça nova — ele segue aberto no Protocolo.' : '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',
// [gesto:app] = ESTE PEDIDO NASCEU DE UM CLIQUE HUMANO, agora, nesta tela.
// Quem lê: watcher_integra.js (gestoDoItem). Com o carimbo ele pode chamar
// o loginAgora — abrir o tribunal e deixar o Autenticador preencher PIN/2FA
// — em vez de só tentar o cache frio do PIN e falhar. Sem o carimbo o
// pedido vira "fundo" e o robô não encosta no certificado. Não tirar.
observacao: isTjsc
? 'Pedido TJSC eproc 1g — integra_eproc_tjsc.js (Playwright + Download Completo). [gesto:app]'
: 'Pedido direto da ficha do processo. [gesto:app]',
}]);
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. [gesto:app]',
}]);
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)'],
arquivado: ['secondary', '🗄 arquivado'], // substituição/refação marca o item antigo como arquivado (nunca erro)
};
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: [] };
}
// Extrai um MOTIVO/erro legível de `distribuicoes.resultado` (jsonb) e `observacao`.
// Estados do robô gravam resultado.erro (string), resultado.erros[] , resultado.pararEm,
// resultado.nota — hoje invisíveis no painel. NÃO repete as `pendencias` (já listadas no
// card). Retorna '' quando não há nada a mostrar.
function _distMotivo(r) {
const res = (r && r.resultado && typeof r.resultado === 'object') ? r.resultado : {};
const cand = [];
const push = (x) => { const s = String(x == null ? '' : x).trim(); if (s) cand.push(s); };
push(res.erro);
if (Array.isArray(res.erros)) res.erros.forEach(push);
push(res.pararEm);
push(res.nota);
['mensagem', 'message', 'detalhe', 'motivo'].forEach((k) => push(res[k]));
if (r) push(r.observacao);
const uniq = [...new Set(cand)];
return uniq.length ? uniq.join(' · ') : '';
}
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,observacao,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 if (r.resultado?.parado && r.status === 'pronto') {
// PARADO SEM NINGUÉM PEGAR (vigia_parados.js, 27/08/2026). Enquanto o item
// esperava por uma máquina que nunca vinha, o card dizia "⏳ preparando…" —
// exatamente a mentira que fez a inicial da Yara passar despercebida. Se o
// vigia marcou, o card diz que está parado, desde quando e por quê.
chip = `⏸ PARADO — ninguém pegou`;
msg = `
${_paradoHa(r.resultado) ? `Parada há ${escape(_paradoHa(r.resultado))} e ninguém pegou` : 'Parada — ninguém pegou'} — ${escape(r.resultado.parado.texto || 'nenhuma máquina assumiu o item')}. ${_paradoDesde(r.resultado) ? `Parada desde ${escape(_paradoDesde(r.resultado))}. ` : ''}Não anda sozinha até isso ser resolvido.
`;
} 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).
` : '';
// Status técnico (raw) + MOTIVO/erro do resultado/observacao — antes ficavam invisíveis
// e o advogado não entendia por que o item travou.
const upd = (r.updated_at || '').slice(0, 10);
const statusTec = `
`;
}
// 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');
}
// ── ITEM PARADO SEM NINGUÉM PEGAR (vigia_parados.js, 27/08/2026) ────────────
// O pior estado da fila não era o erro: era o SILÊNCIO. Item cujo dono não é de
// nenhuma máquina ligada (ou cujo robô caiu) ficava ali sem erro, sem aviso e
// sem nada na tela — os embargos do Mauro passaram um dia assim. O vigia local
// grava `resultado.parado` e estas duas funções traduzem isso pro card.
// `parado.ref_em` é a última movimentação REAL do item (não a hora em que o
// vigia escreveu), então o "há quanto tempo" é honesto.
function _paradoHa(res) {
const p = res && res.parado; if (!p) return '';
const t = Date.parse(p.ref_em || p.visto_em || ''); if (!t) return '';
const min = Math.max(1, Math.round((Date.now() - t) / 60000));
if (min < 90) return `${min} min`;
const h = Math.round(min / 60);
return h < 36 ? `${h}h` : `${Math.round(h / 24)} dias`;
}
function _paradoDesde(res) {
const p = res && res.parado; if (!p) return '';
const d = new Date(p.ref_em || p.visto_em || ''); if (isNaN(d)) return '';
return d.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
}
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)'];
}
// SEM ADVOGADO RESPONSÁVEL (roteamento por máquina, 05/08/2026): o item não
// tem responsavel_padrao_id nem responsaveis_ids no processo, e o watcher
// NÃO pode processá-lo (regra do George: nenhuma máquina atua sem dono —
// evita 2 robôs pegando a mesma peça). O card avisa o que falta fazer.
if (r.resultado?.aviso_sem_responsavel && (r.status === 'aprovado' || r.status === 'aguardando_pdf')) {
st = ['warning', '⏸ sem advogado responsável no processo'];
}
// PARADO SEM NINGUÉM PEGAR (vigia_parados): nenhuma máquina assumiu o item.
// Vem por último de propósito — se está parado em silêncio, é ISSO que o
// advogado precisa ler, não o "⏳ preparando no tribunal" que mente.
if (r.resultado?.parado && ['aprovado', 'aguardando_pdf', 'aguardando_integra'].includes(r.status)) {
st = ['danger', _paradoHa(r.resultado) ? `⏸ parado há ${_paradoHa(r.resultado)} — ninguém pegou` : '⏸ parado — ninguém pegou'];
}
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 if (r.status === 'erro' && (r.tipo === 'protocolo' || !r.tipo)) {
// ERRO TERMINAL (tribunal fora do ar, falha no meio do caminho): a peça JÁ
// EXISTE (PDF pronto) — reenviar reaproveita o MESMO item, SEM gerar de novo
// e SEM custo de IA. "Já resolvi" = o advogado corrigiu a causa (logou,
// tribunal voltou) e o robô parte dali (pedido do George 28/07).
acoes = `${verPdf}
`;
} 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?.parado && ['aprovado', 'aguardando_pdf', 'aguardando_integra'].includes(r.status)) ? ` ⏸ Parado — ninguém pegou este item — ${escape(r.resultado.parado.texto || 'nenhuma máquina assumiu o item')}.${_paradoDesde(r.resultado) ? ` Parado desde ${escape(_paradoDesde(r.resultado))} (${escape(_paradoHa(r.resultado))}).` : ''} Não anda sozinho.` : ''}${(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 => statusProc(p) !== 'Encerrado' && p.ativo !== false).length;
const encerrados = processos.filter(p => statusProc(p) === '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 `
TOTAL — ${rows.length} operação(ões)${nReal < rows.length ? ` (${nReal} com custo real)` : ''}
${brl(tReal)}
${brl(tCobrado)}
${brl(tLucro)}
`;
}
// Exporta o que está filtrado como CSV (ponto-e-vírgula; abre no Excel BR).
function relIADetExportarCSV() {
const rows = relIADetFiltradas();
if (!rows.length) { toast('Nada para exportar no filtro atual.', 'warning'); return; }
const cab = ['Data/hora','Tipo','Feature','Fonte','Cliente/Processo','Modelo','Tokens in','Tokens out','Custo real R$','Cobrado R$','Lucro R$'];
const esc = (v) => {
const s = (v == null ? '' : String(v));
return /[";\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
};
const num = (v) => (v == null ? '' : String(Number(v).toFixed(4)).replace('.', ','));
const linhas = rows.map(r => {
const dt = new Date(r.data);
const dataFmt = isNaN(dt) ? '' : dt.toLocaleString('pt-BR');
const cliProc = [r.descricao, r.processoNum].filter(Boolean).join(' · ');
const lucro = (r.custoReal != null && r.cobrado != null) ? (r.cobrado - r.custoReal) : null;
return [dataFmt, relIAFeatureLabel(r.feature), r.feature, r.fonte, cliProc, r.modelo || '',
(r.tin == null ? '' : r.tin), (r.tout == null ? '' : r.tout),
num(r.custoReal), num(r.cobrado), num(lucro)].map(esc).join(';');
});
const csv = '' + [cab.join(';')].concat(linhas).join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'custo-ia-detalhado-' + new Date().toISOString().slice(0, 10) + '.csv';
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 2000);
}
// ----- 5) PERFORMANCE -----
async function relRenderPerformance(el) {
const hoje = new Date().toISOString().slice(0,10);
const mesIni = new Date().toISOString().slice(0,7) + '-01';
/* PERF/DISPONIBILIDADE (13/09): este relatório lia `andamentos` INTEIRA (132k
linhas, select=* com offset profundo) pra exibir DOIS números. Agora os dois
saem de COUNT no servidor — nenhuma linha viaja. Mesmo motivo do incidente
de 12/09/2026 (statement timeout → 500 na tela do advogado). */
const [eventos, processos, andamentosMes, andamentosMesAuto] = await Promise.all([
dbGetAll('agenda_eventos'),
dbGetAll('processos'),
dbCount('andamentos', q => q.gte('data', mesIni)),
dbCount('andamentos', q => q.gte('data', mesIni).eq('origem', 'datajud')),
]);
// Prazos: concluídos vs atrasados vs em dia
const prazos = eventos.filter(e => e.tipo === 'Prazo');
const prazosConcluidos = prazos.filter(e => e.concluido).length;
const prazosAtrasados = prazos.filter(e => !e.concluido && e.data < hoje).length;
const prazosEmDia = prazos.filter(e => !e.concluido && e.data >= hoje).length;
const taxaCumprimento = prazos.length ? ((prazosConcluidos / (prazosConcluidos + prazosAtrasados || 1)) * 100) : 0;
// Peças geradas (inicial_entrevistas com peça)
const { data: inicGen } = await sb.from('inicial_entrevistas').select('id,created_at,status').eq('status', 'gerada');
const pecasGeradas = (inicGen || []).length;
const pecasMes = (inicGen || []).filter(x => (x.created_at || '') >= mesIni).length;
// Audiências futuras
const audiencias = eventos.filter(e => e.tipo === 'Audiência' && e.data >= hoje && !e.concluido);
el.innerHTML = `
${relCard('Taxa de cumprimento de prazos', taxaCumprimento.toFixed(1) + '%', `${prazosConcluidos} ok · ${prazosAtrasados} perdidos`, taxaCumprimento >= 90 ? '#10B981' : (taxaCumprimento >= 70 ? '#F59E0B' : '#EF4444'))}
${relCard('Prazos pendentes', prazosEmDia, `${prazosAtrasados} já atrasados`, prazosAtrasados ? '#EF4444' : '#10B981')}
${relCard('Audiências marcadas', audiencias.length, 'próximas')}
${relCard('Peças IA geradas', pecasGeradas, `${pecasMes} este mês`, '#00E5FF')}
${/* O "automáticos" contava origem==='auto' || fonte==='datajud' — nenhum dos dois
existe (a coluna origem só aceita 'manual'|'datajud' e `fonte` não existe na
tabela), então este número era SEMPRE 0. Agora conta origem='datajud' DENTRO
do mês, casando com o rótulo do card. */''}
${relCard('Andamentos este mês', andamentosMes ?? '—', `${andamentosMesAuto ?? '—'} automáticos do DataJud/DJEN`)}
${relCard('Processos com IA ativa', processos.filter(p => p.ativo !== false).length, 'IA agendando prazos das publicações')}
${relCard('Processos cadastrados', processos.length, '')}
${relCard('Total de eventos na agenda', eventos.length, `${eventos.filter(e => e.concluido).length} concluídos`)}
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).
Vazio = texto padrão do IURIA. Escreva o parágrafo completo, com redação que sirva para um ou mais outorgados. Somente o marcador {finalidade} é substituído (pela finalidade específica, quando houver — sem ela, o marcador some do documento).
${ic('fileText')} Modelos de Peça
Minutas e cláusulas reutilizáveis do escritório
Carregando…
${ic('image')} Logotipo do escritório
Imagem que aparece no cabeçalho de todas as peças, procurações e contratos gerados pelo IURIA
Faça upload do logotipo do seu escritório. Ele será inserido no topo de todas as peças, procurações, contratos e documentos gerados pelo IURIA. Formatos aceitos: PNG, JPG, SVG. Tamanho máximo: 500 KB.
\n\n
\n
${ic('fileText')} Dados do escritório nos documentos
Nome, endereço e contato que aparecem no cabeçalho das procurações, contratos e peças geradas
\n
\n
\n
Estes dados aparecem no cabeçalho dos documentos PDF logo acima do título. Se deixar vazio, usa o nome do advogado outorgado (comportamento atual).
\n
\n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n
\n
\n\n
\n
${ic('globe')} Tribunais que abrem ao iniciar
Quais sistemas o computador abre automaticamente no Chrome quando você liga (pra captar e protocolar)
Marque os tribunais/sistemas que o seu escritório usa. No próximo boot (ou quando o sistema reabrir o navegador), as abas seguem esta lista. Você ainda loga manualmente em cada um — aqui só define quais abrem.
Carregando…
${ic('settings')} Tema do IURIA
Escolha a aparência do sistema — cada advogado pode ter a sua
${_temaCardBody()}
${ic('edit')} Aparência do texto
Fonte, tamanho da letra, espaçamento e acabamento — do seu jeito, neste aparelho
${_apCardBody()}
${ic('calendar')} Ordem do painel
O que você quer ver primeiro ao abrir o IURIA — a lista do que vence ou o calendário do mês. Vale neste aparelho.
${[
{ v: 'prazos', t: 'Prazos primeiro', d: 'A lista do que vence em cima; o calendário do mês logo abaixo.' },
{ v: 'calendario', t: 'Calendário primeiro', d: 'O calendário do mês em cima; a lista de prazos abaixo. É como está hoje.' },
{ v: 'so_prazos', t: 'Só os prazos', d: 'Sem calendário no painel. Ele continua inteiro na aba Agenda.' },
].map(o => `
`).join('')}
${ic('user')} Meu nome
Como você aparece no sistema (saudação, responsáveis, peças). Ex.: acrescente o sobrenome para diferenciar do nome do escritório.
${ic('bell')} Notícias do painel
Fontes que rodam no ticker do topo do Dashboard — STF e STJ já vêm ligadas; marque outras ou adicione a sua
Minhas fontes
Adicione um site (ex.: globo.com), um assunto (ex.: reforma tributária) ou uma URL de RSS — o IURIA busca as notícias e traz no ticker. Máximo de 6 fontes no total.
${ic('image')} Criar logo dourado 3D
A IURIA monta um logo dourado em 3D a partir da sigla do seu escritório — 3 sugestões para escolher. Grátis, sem enviar arquivo.
${ic('calendar')} Aparência do calendário
Escolha o visual que fica melhor para você — em todos, a cor significa urgência
Modelo do calendário
${_calModeloCardBody()}
Marcador do dia
${_calEstiloCardBody()}
Tarefas delegadas
${_delegModeloCardBody()}
${ic('lock')} Alterar minha senha
Troca a senha de acesso ao IURIA. Vale para este login em todos os aparelhos.
${ic('shield')} Verificação em duas etapas (2FA)
Proteja seu acesso com um app autenticador (Google Authenticator, Authy, etc.)
Ao ativar a verificação em duas etapas, além da senha, você precisará informar um código temporário gerado pelo seu app autenticador toda vez que fizer login.
Carregando…
${ic('shield')} Privacidade e dados (LGPD)
Seus direitos como titular — Lei 13.709/2018, art. 18
Consulte as nossas políticas, exporte uma cópia completa dos dados do seu escritório ou solicite a exclusão da conta.
Exportar meus dados
Gera um arquivo JSON com os dados do seu escritório (perfil, clientes, processos, prazos, publicações, andamentos, honorários, financeiro, documentos). Inclui apenas os dados do seu escritório.
Excluir minha conta e meus dados
Registra um pedido de exclusão da conta e de todos os dados do escritório. Por se tratar de ação irreversível e que pode envolver obrigações legais de guarda, a exclusão é processada manualmente pela nossa equipe em até 15 dias, conforme a LGPD. Você receberá confirmação por e-mail.
Abrir chamado / reembolso
Precisa de suporte, quer cancelar a assinatura ou solicitar reembolso de valores? Você tem direito de arrependimento em 7 dias na compra online (CDC, art. 49). Abra um chamado e nossa equipe responde por e-mail/WhatsApp.
${ic('bell')} Notificações e App
Instale o IURIA e receba avisos de publicações e prazos
Instalar o app
Adicione o IURIA à tela inicial do celular ou desktop, abrindo em tela cheia como um aplicativo.
✅ App já instalado neste dispositivo.
Notificações push
Receba um aviso quando chegarem novas publicações ou prazos vencendo. Vale por dispositivo (ative em cada celular/computador que usar).
Carregando…
${ic('message')} Assistente no Telegram Profissional
Seu assistente do IURIA no celular: resumo do dia, prazos, autos do processo e minuta para revisar — tudo por chat
Carregando…
${ic('folder')} Gestor de Arquivos Profissional
Arquivo do celular para o computador sem WhatsApp Web, com pastas e link com senha pro cliente
Carregando…
Sobre o IURIA
Versão 1.0
Plataforma completa de gestão jurídica com Inteligência Artificial. Seus dados ficam seguros na nuvem (infraestrutura Supabase, criptografada), com sincronização em tempo real entre dispositivos e backup automático. Em conformidade com a LGPD.
`;
document.getElementById('btnExport').onclick = exportAll;
document.getElementById('fileImport').onchange = importAll;
document.getElementById('filePromadImport').onchange = importarDoPromad;
const btnExpLgpd = document.getElementById('btnExportLgpd');
if (btnExpLgpd) btnExpLgpd.onclick = exportarDadosLgpd;
const btnExcLgpd = document.getElementById('btnExclusaoLgpd');
if (btnExcLgpd) btnExcLgpd.onclick = solicitarExclusaoLgpd;
lgpdRenderStatusExclusao();
// Mostra seção de Usuários só pra admin
carregarSecaoUsuariosAdmin();
// Painel de Saúde do sistema — só admin do escritório OU owner da plataforma
carregarSaudeSistema();
// Crédito de IA — visível pra TODO advogado: ele vai perguntar "no que gastei
// meus R$ 30" e a resposta precisa estar à mão, não só pro admin.
try { creditoIARender(); } catch (e) {}
advCapturaRender();
advQualRender();
logoEscritorioCarregar();
brandingConfigCarregar();
docConfigCarregar();
try { logoAutoPrefill(); } catch (e) {}
try { noticiasConfigRender(); } catch (e) {}
try { meuNomeCarregar(); } catch (e) {}
tribunaisAbasRender();
assistenteConfigRender();
try { arquivosConfigRender(); } catch (e) {}
modelosPecaCarregar();
// MFA — status da verificação em duas etapas
mfaRender();
// PWA: estado do botão instalar + notificações push
try {
_atualizarBotaoInstalar();
const jaInst = document.getElementById('pwaJaInstalado');
const btnInst = document.getElementById('btnInstalarPWA');
if (_isStandalonePWA()) { if (jaInst) jaInst.style.display = ''; if (btnInst) btnInst.style.display = 'none'; }
if (btnInst) btnInst.onclick = instalarPWA;
pushRenderEstado();
} catch (e) { console.warn('[pwa] config init', e); }
};
// ===================================================================
// Assistente no Telegram — vínculo do chat do advogado ao IURIA.
// O advogado gera um código aqui e envia /start pro bot; a edge
// assistente-telegram valida e liga o telegram_chat_id à conta dele.
// Segurança: só o próprio usuário mexe na sua linha (RLS por user_id).
//
// TUTORIAL INTERATIVO (28/07): o assistente virou recurso pago (Profissional/
// Premium) e ninguém paga pelo que não sabe ligar. Então esta tela deixou de
// ser um "gere um código" seco e virou um passo a passo de 3 etapas que abre o
// Telegram no bot certo já com o código embutido, copia o que for preciso e
// AVISA SOZINHA quando o vínculo acontece (sem recarregar a página).
// Quem está no Essencial vê o mesmo tutorial, sem o código, com o convite pra
// assinar: mostrar o que ele ganharia é venda; esconder é desperdício.
// ===================================================================
const ASSISTENTE_BOT_HANDLE = '@IuriaAssistenteBot'; // bot criado no @BotFather
const ASSISTENTE_BOT_USER = 'IuriaAssistenteBot'; // sem @ — usado pra montar o link t.me
// Link direto: com o código, o Telegram já manda "/start CODIGO" quando o
// advogado toca em INICIAR — ele não digita nada.
function _assistenteLinkBot(codigo) {
return 'https://t.me/' + ASSISTENTE_BOT_USER + (codigo ? '?start=' + encodeURIComponent(codigo) : '');
}
// O que dá pra pedir depois de conectar. Frases reais, em português de
// advogado — nada de "comandos". Tocar copia a frase pra colar no chat.
const ASSISTENTE_EXEMPLOS = [
'Me dá o resumo do dia',
'Quais são meus prazos desta semana?',
'Do que se trata o processo do Fulano?',
'Baixa os autos do processo do Fulano',
'Me manda a sentença desse processo',
'Prepara a minuta da manifestação sobre o laudo',
];
let _assistenteAutoCodigo = false; // já tentei gerar o código sozinho nesta tela
let _assistenteVigiaTimer = null; // relógio do código + detector do vínculo
let _assistenteVigiaTicks = 0;
function _assistenteGerarCodigoAleatorio() {
const abc = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // sem 0/O/1/I pra não confundir
const arr = new Uint32Array(6);
try { window.crypto.getRandomValues(arr); }
catch { for (let i = 0; i < 6; i++) arr[i] = Math.floor(Math.random() * 4294967296); }
let s = '';
for (let i = 0; i < 6; i++) s += abc[arr[i] % abc.length];
return s;
}
// Card do Gestor de Arquivos nas Configurações (28/07). Quem não tem o recurso
// no plano VÊ o recurso, marcado como indisponível, com o convite pra assinar —
// some da tela seria pior: quem não vê, não deseja.
async function arquivosConfigRender() {
const wrap = document.getElementById('arquivosConfigWrap');
if (!wrap) return;
try {
const { data: { user } } = await sb.auth.getUser();
if (!user) { wrap.innerHTML = '
Cadastre sua OAB no perfil para receber sua área de arquivos — o endereço dela é a sua inscrição.
';
return;
}
// Caminho RELATIVO de propósito: assim o link segue o ambiente em que o app
// está rodando (teste abre o Gestor do teste, produção o de produção). URL
// absoluta aqui jogaria quem testa direto na produção.
const url = `/arquivos/${uf}${dig}`;
wrap.innerHTML = `
';
}
}
window.arquivosConfigRender = arquivosConfigRender;
// ---------- peças visuais do tutorial (sem biblioteca, só HTML/CSS do IURIA) ----------
// Um passo do passo a passo. estado: 'ativo' | 'feito' | 'espera'.
function _assistentePasso(n, titulo, corpoHtml, estado) {
const feito = estado === 'feito';
const espera = estado === 'espera';
const bolha = feito
? `background:var(--success);color:#fff`
: (espera ? `background:var(--surface-alt);color:var(--text-muted);border:1px solid var(--border)` : `background:var(--primary);color:#fff`);
return `
${feito ? ic('check', 15) : n}
${titulo}
${corpoHtml}
`;
}
// Exemplos clicáveis (tocar copia a frase). Sem onclick com aspas dentro:
// leio o próprio texto do botão, então nenhuma frase precisa de escape em JS.
function _assistenteExemplosHtml(titulo) {
const chips = ASSISTENTE_EXEMPLOS.map(t =>
``
).join('');
return `
${titulo || 'O que você pode pedir a ele'}
Escreva como falaria com um estagiário — ou mande um áudio. Toque numa frase para copiar.
${chips}
Ele consulta, lê os autos e prepara a minuta — mas nunca assina nem protocola. A assinatura é sempre sua.
`;
}
// Tela de quem já está conectado.
function _assistenteConectadoHtml(vinc) {
const quem = vinc.telegram_username ? '@' + vinc.telegram_username : (vinc.telegram_nome || '');
return `
${ic('checkCircle')} Assistente conectado
${quem ? 'Conversando com você no Telegram como ' + escape(quem) + '.' : 'Seu Telegram está ligado à sua conta do IURIA.'}
Todo dia de manhã ele manda o resumo das publicações; fora isso, é só chamar.
${_assistenteExemplosHtml()}`;
}
// Passo a passo de quem ainda não conectou.
function _assistentePassosHtml(vinc) {
const codigo = (vinc && vinc.codigo_vinculo) || '';
const expiraEm = (vinc && vinc.codigo_expira_em) ? new Date(vinc.codigo_expira_em) : null;
const codigoAtivo = !!codigo && !!expiraEm && expiraEm > new Date();
const venceu = !!codigo && !!expiraEm && expiraEm <= new Date();
// Passo 1 — abrir o bot. Com código, o link já leva o vínculo junto: o
// advogado toca em INICIAR no Telegram e acabou (nada de digitar).
const passo1 = codigoAtivo ? `
O botão abaixo abre o Telegram direto no assistente ${escape(ASSISTENTE_BOT_HANDLE)}, já com o seu código. Lá, toque em INICIAR (ou START).
Gere o código no passo 2 e este botão passa a abrir o Telegram já conectando. Se preferir, procure por ${escape(ASSISTENTE_BOT_HANDLE)} na busca do Telegram.
`;
// Passo 2 — o código.
let passo2;
if (codigoAtivo) {
passo2 = `
Se o Telegram abrir sem o código, mande esta mensagem para ele: /start ${escape(codigo)}
${escape(codigo)}
${ic('clock', 13)} vale por mais 15:00
`;
} else {
passo2 = `
${venceu
? 'O código anterior venceu (ele vale 15 minutos, por segurança). Gere outro — leva um segundo.'
: 'É um código de 6 letras e números, só seu, válido por 15 minutos. Ele é o que prova ao assistente que aquele Telegram é o seu.'}
Esperando você tocar em INICIAR no Telegram. Assim que isso acontecer, esta tela avisa sozinha — não precisa recarregar nada.
`
: `
Assim que o Telegram responder, esta tela confirma a conexão sozinha e mostra com qual conta você ficou ligado.
`;
return `
Em menos de um minuto o assistente passa a te atender no celular: resumo do dia, prazos da semana, autos do processo e minuta pronta para revisão.
${_assistentePasso(1, 'Abra o assistente no Telegram', passo1, codigoAtivo ? 'ativo' : 'espera')}
${_assistentePasso(2, codigoAtivo ? 'Seu código já está pronto' : 'Gere o seu código', passo2, codigoAtivo ? 'feito' : 'ativo')}
${_assistentePasso(3, 'Pronto — a confirmação é automática', passo3, 'espera')}
Não conectou? Veja o que costuma ser
O assistente respondeu "código inválido ou expirado": o código vale 15 minutos. Gere outro no passo 2 e mande de novo.
Digitou o código na mão e não funcionou: use Copiar a mensagem pronta — ela já vem com o /start na frente, que é o que o assistente espera.
Abriu outro robô parecido: confira o nome ${escape(ASSISTENTE_BOT_HANDLE)} antes de mandar o código.
Esse Telegram já estava ligado a outra conta do IURIA: vale sempre a última conexão — a anterior deixa de responder naquele aparelho.
Continua sem conectar: fale com o suporte pelo WhatsApp.
${_assistenteExemplosHtml('Depois de conectar, você pode pedir')}
`;
}
// Prévia pra quem ainda não tem o recurso no plano: ele VÊ o passo a passo
// (sem código) e os exemplos. Quem não vê, não deseja.
function _assistenteTutorialPreviaHtml() {
return `
Como funciona quando você ativa
Três passos, menos de um minuto:
${_assistentePasso(1, 'Abrir o assistente no Telegram', `
Um botão aqui abre o Telegram direto no assistente ${escape(ASSISTENTE_BOT_HANDLE)}.
`, 'espera')}
${_assistentePasso(2, 'Confirmar que é você', `
O IURIA gera um código só seu, que vai junto no botão — você não digita nada.
`, 'espera')}
${_assistentePasso(3, 'Usar', `
A partir daí ele te manda o resumo do dia toda manhã e responde no chat, esteja você onde estiver.
`, 'espera')}
${_assistenteExemplosHtml('O que você poderia pedir a ele')}
`;
}
async function assistenteConfigRender() {
const wrap = document.getElementById('assistenteConfigWrap');
if (!wrap) return;
_assistenteVigiaParar();
try {
const { data: { user } } = await sb.auth.getUser();
if (!user) { wrap.innerHTML = '
Faça login para configurar o assistente.
'; return; }
const { data: perfil } = await sb.from('perfis_usuario').select('escritorio_id').eq('user_id', user.id).maybeSingle();
// DUAS PERGUNTAS DIFERENTES, na mesma ordem do servidor:
// 1) o PLANO inclui o assistente? -> se não, convite pra assinar.
// 2) o ESCRITÓRIO ligou? -> vale DENTRO do plano.
// Não troque uma pela outra: "não está no seu plano" e "está desligado no
// seu escritório" são problemas diferentes e têm saídas diferentes.
const rec = await iuriaRecursos();
const infoAss = rec && rec.assistente;
const planoOk = infoAss ? !!infoAss.liberado : true; // sem resposta: deixa o servidor barrar
let ligado = false;
if (perfil?.escritorio_id) {
const { data: esc } = await sb.from('escritorios').select('assistente_ativo').eq('id', perfil.escritorio_id).maybeSingle();
ligado = !!esc?.assistente_ativo;
}
const { data: vinc } = await sb.from('assistente_telegram_users')
.select('id, telegram_chat_id, telegram_nome, telegram_username, vinculado_em, codigo_vinculo, codigo_expira_em')
.eq('user_id', user.id).maybeSingle();
if (!planoOk) {
wrap.innerHTML = iuriaCaixaUpgrade(infoAss) + _assistenteTutorialPreviaHtml();
return;
}
if (!ligado) {
wrap.innerHTML = `
Seu plano inclui o Assistente no Telegram, mas ele ainda está desligado no seu escritório.
${_assistenteTutorialPreviaHtml()}`;
return;
}
if (vinc && vinc.telegram_chat_id) {
wrap.innerHTML = _assistenteConectadoHtml(vinc);
return;
}
// Sem código válido? Gera um sozinho, uma vez, pra o passo 1 já sair
// pronto (um toque e conecta). Se a gravação falhar, cai no botão manual.
const codigoAtivo = vinc && vinc.codigo_vinculo && vinc.codigo_expira_em && (new Date(vinc.codigo_expira_em) > new Date());
if (!codigoAtivo && !_assistenteAutoCodigo) {
_assistenteAutoCodigo = true; // marca ANTES: uma tentativa, sem laço
if (await assistenteGerarCodigo(true)) { return assistenteConfigRender(); }
}
wrap.innerHTML = _assistentePassosHtml(vinc);
if (vinc && vinc.codigo_vinculo && vinc.codigo_expira_em && new Date(vinc.codigo_expira_em) > new Date()) {
_assistenteVigiaIniciar(user.id, vinc.codigo_expira_em);
}
} catch (e) {
console.warn('assistenteConfigRender:', e?.message);
wrap.innerHTML = '
Não consegui carregar o assistente agora.
';
}
}
window.assistenteConfigRender = assistenteConfigRender;
// silencioso=true: só grava e devolve true/false (quem chamou redesenha a tela).
async function assistenteGerarCodigo(silencioso) {
const msg = document.getElementById('assistenteMsg');
try {
const { data: { user } } = await sb.auth.getUser();
if (!user) return false;
const { data: perfil } = await sb.from('perfis_usuario').select('escritorio_id').eq('user_id', user.id).maybeSingle();
const codigo = _assistenteGerarCodigoAleatorio();
const expira = new Date(Date.now() + 15 * 60 * 1000).toISOString();
const { data: existente } = await sb.from('assistente_telegram_users').select('id').eq('user_id', user.id).maybeSingle();
if (existente) {
const { error } = await sb.from('assistente_telegram_users')
.update({ codigo_vinculo: codigo, codigo_expira_em: expira, atualizado_em: new Date().toISOString() }).eq('id', existente.id);
if (error) throw error;
} else {
const { error } = await sb.from('assistente_telegram_users')
.insert({ user_id: user.id, escritorio_id: perfil?.escritorio_id || null, codigo_vinculo: codigo, codigo_expira_em: expira });
if (error) throw error;
}
if (!silencioso) await assistenteConfigRender();
return true;
} catch (e) {
// No modo silencioso não assusta ninguém: a tela cai no botão manual.
if (!silencioso && msg) { msg.style.color = '#B91C1C'; msg.textContent = 'Não consegui gerar o código agora: ' + (e?.message || e) + '. Tente de novo em instantes.'; }
return false;
}
}
window.assistenteGerarCodigo = assistenteGerarCodigo;
// Copiar sem biblioteca: clipboard moderno com queda para o textarea antigo
// (celular em contexto sem permissão de área de transferência).
async function assistenteCopiarTexto(texto, aviso) {
const t = String(texto || '');
let ok = false;
try { await navigator.clipboard.writeText(t); ok = true; } catch (e) {
try {
const ta = document.createElement('textarea');
ta.value = t; ta.setAttribute('readonly', '');
ta.style.position = 'fixed'; ta.style.top = '-1000px';
document.body.appendChild(ta); ta.select();
ok = document.execCommand('copy');
document.body.removeChild(ta);
} catch (e2) { ok = false; }
}
try { toast(ok ? (aviso || 'Copiado') : 'Não consegui copiar — selecione o texto e copie na mão', ok ? 'success' : 'error'); } catch (e) {}
}
window.assistenteCopiarTexto = assistenteCopiarTexto;
// ---- Vigia do vínculo: a tela confirma sozinha quando o Telegram responde ----
// Um relógio de 1s cuida de duas coisas: a contagem do código e, a cada 4s,
// uma consulta curta ao banco. Ele se desliga sozinho quando o advogado sai da
// tela, quando o código vence ou quando o vínculo acontece — nunca fica rodando
// para sempre nem consulta com a aba escondida.
function _assistenteVigiaParar() {
if (_assistenteVigiaTimer) { clearInterval(_assistenteVigiaTimer); _assistenteVigiaTimer = null; }
_assistenteVigiaTicks = 0;
}
function _assistenteVigiaIniciar(userId, expiraIso) {
_assistenteVigiaParar();
const expira = expiraIso ? new Date(expiraIso).getTime() : 0;
_assistenteVigiaTimer = setInterval(async () => {
const alvo = document.getElementById('assistentePassos');
if (!alvo || !document.body.contains(alvo)) { _assistenteVigiaParar(); return; } // saiu da tela
_assistenteVigiaTicks++;
// 1) relógio do código
if (expira) {
const resta = Math.max(0, Math.round((expira - Date.now()) / 1000));
const el = document.getElementById('assistenteCodigoPrazo');
if (el) el.innerHTML = resta > 0
? ic('clock', 13) + ' vale por mais ' + Math.floor(resta / 60) + ':' + String(resta % 60).padStart(2, '0')
: ic('alert', 13) + ' este código venceu';
if (resta <= 0) { _assistenteVigiaParar(); assistenteConfigRender(); return; }
}
// 2) o vínculo já aconteceu? (a cada 4s, e só com a aba à vista)
if (_assistenteVigiaTicks % 4 !== 0 || document.hidden) return;
try {
const { data } = await sb.from('assistente_telegram_users')
.select('telegram_chat_id').eq('user_id', userId).maybeSingle();
if (data && data.telegram_chat_id) {
_assistenteVigiaParar();
try { toast('Assistente conectado', 'success'); } catch (e) {}
assistenteConfigRender();
}
} catch (e) { /* rede instável: tenta no próximo ciclo */ }
}, 1000);
}
async function assistenteDesvincular() {
if (!confirm('Desconectar seu Telegram do IURIA? Você deixa de receber o resumo do dia e de comandar pelo chat até conectar de novo.')) return;
try {
const { data: { user } } = await sb.auth.getUser();
if (!user) return;
const { error } = await sb.from('assistente_telegram_users')
.update({ telegram_chat_id: null, vinculado_em: null, codigo_vinculo: null, codigo_expira_em: null, atualizado_em: new Date().toISOString() })
.eq('user_id', user.id);
if (error) throw error;
_assistenteAutoCodigo = false; // ao voltar pro passo a passo, já sai com código novo
await assistenteConfigRender();
} catch (e) { alert('Erro ao desconectar: ' + (e?.message || e)); }
}
window.assistenteDesvincular = assistenteDesvincular;
// ===================================================================
// LGPD — direitos do titular (art. 18): exportar dados e pedir exclusão.
// Tudo respeitando o tenant: as queries usam o cliente autenticado, então o
// RLS por escritório (escritorios_select/update, tenant_*) limita ao próprio
// escritório. O pedido de exclusão é REGISTRADO (não apaga nada na hora) — é o
// caminho mais seguro: evita perda irreversível e permite a guarda legal.
// ===================================================================
// Tabelas do tenant incluídas na exportação. Só dados do próprio escritório
// (garantido por RLS no servidor). Mantido conservador: nada de tabelas de
// plataforma (iuria_owners, ia_custo_real, trials_oab) nem custo interno.
const LGPD_EXPORT_TABELAS = [
'escritorios', 'perfis_usuario', 'clientes', 'processos', 'partes_adversas',
'honorarios', 'agenda_eventos', 'andamentos', 'publicacoes', 'documentos',
'grupos_clientes', 'financeiro_lancamentos', 'requisitorios',
'advogados_qualificacao', 'advogados_captura',
];
async function exportarDadosLgpd() {
const btn = document.getElementById('btnExportLgpd');
if (btn) { btn.disabled = true; btn.textContent = 'Gerando...'; }
try {
const { data: { user } } = await sb.auth.getUser();
const out = {
_documento: 'Exportação de dados pessoais — LGPD art. 18 (portabilidade/acesso)',
_gerado_em: new Date().toISOString(),
_titular_usuario: user ? { id: user.id, email: user.email } : null,
_observacao: 'Inclui apenas os dados do seu escritório. Dados de clientes constam por serem tratados pelo escritório (controlador).',
};
for (const t of LGPD_EXPORT_TABELAS) {
const { data, error } = await sb.from(t).select('*');
out[t] = error ? { _erro: error.message } : (data || []);
}
const blob = new Blob([JSON.stringify(out, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `iuria_meus_dados_${new Date().toISOString().slice(0,10)}.json`;
a.click();
URL.revokeObjectURL(url);
toast('Exportação gerada.', 'success');
} catch (e) {
toast('Erro ao exportar: ' + (e.message || e), 'error');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = ic('download') + ' Exportar meus dados (.json)'; }
}
}
// Renderiza o status do pedido de exclusão e mostra o botão só pro admin.
async function lgpdRenderStatusExclusao() {
const wrap = document.getElementById('exclusaoStatusWrap');
const btn = document.getElementById('btnExclusaoLgpd');
if (!wrap) return;
try {
const { data: { user } } = await sb.auth.getUser();
if (!user) return;
const { data: perfil } = await sb.from('perfis_usuario').select('role, escritorio_id').eq('user_id', user.id).maybeSingle();
const ehAdmin = perfil?.role === 'admin';
if (!perfil?.escritorio_id) { wrap.innerHTML = ''; return; }
const { data: esc } = await sb.from('escritorios').select('exclusao_solicitada_em').eq('id', perfil.escritorio_id).maybeSingle();
if (esc?.exclusao_solicitada_em) {
wrap.innerHTML = `
${ic('clock')} Pedido de exclusão registrado em ${fmtDate(esc.exclusao_solicitada_em)}. Nossa equipe está processando — você receberá confirmação por e-mail. Para cancelar, fale com o suporte.
Erro ao carregar: ${escape(e.message || String(e))}
`;
}
}
// Uma linha de OAB no formulário (UF + número). idx só pra unicidade do DOM.
function _advQualOabRow(uf, numero, idx) {
const opts = UFS_BR.map(u => ``).join('');
return `
`;
}
// Lê o formulário → objeto pronto pro banco (oabs como jsonb array). null se inválido.
function _advQualLer() {
const nome = (document.getElementById('advqual_nome')?.value || '').trim();
if (!nome) { toast('Informe o nome do advogado', 'danger'); return null; }
const oabs = [];
document.querySelectorAll('#advqualOabs .advqual-oab-row').forEach(row => {
const uf = (row.querySelector('.advqual-oab-uf')?.value || '').trim().toUpperCase();
const numero = (row.querySelector('.advqual-oab-num')?.value || '').trim();
if (uf || numero) oabs.push({ uf, numero });
});
if (!oabs.length) { toast('Cadastre pelo menos uma OAB', 'danger'); return null; }
return {
nome,
genero: document.getElementById('advqual_genero')?.value || null,
nacionalidade: (document.getElementById('advqual_nacionalidade')?.value || '').trim() || 'brasileiro(a)',
profissao: (document.getElementById('advqual_profissao')?.value || '').trim() || 'advogado(a)',
oabs,
};
}
function advQualNovo() {
if (!_advQualPodeGerir) { toast('Apenas o administrador do escritório pode gerenciar advogados', 'danger'); return; }
openModal('Novo advogado', _advQualForm(null), `
`);
}
async function advQualEditar(id) {
if (!_advQualPodeGerir) { toast('Apenas o administrador do escritório pode gerenciar advogados', 'danger'); return; }
const { data, error } = await sb.from('advogados_qualificacao')
.select('id,nome,nacionalidade,profissao,genero,oabs').eq('id', id).maybeSingle();
if (error || !data) { toast('Não encontrado', 'danger'); return; }
openModal('Editar advogado', _advQualForm(data), `
`);
}
async function advQualSalvar(id) {
if (!_advQualPodeGerir) { toast('Apenas o administrador do escritório pode gerenciar advogados', 'danger'); return; }
const payload = _advQualLer();
if (!payload) return;
let error;
if (id) {
({ error } = await sb.from('advogados_qualificacao').update(payload).eq('id', id));
} else {
({ error } = await sb.from('advogados_qualificacao').insert([payload]));
}
if (error) { toast('Erro: ' + error.message, 'danger'); return; }
closeModal();
toast(id ? 'Advogado atualizado' : 'Advogado cadastrado', 'success');
await carregarAdvogados(true); // invalida o cache das caixinhas
advQualRender();
}
async function advQualToggle(id, ativo) {
if (!_advQualPodeGerir) { toast('Apenas o administrador do escritório pode gerenciar advogados', 'danger'); return; }
const { error } = await sb.from('advogados_qualificacao').update({ ativo: !!ativo }).eq('id', id);
if (error) { toast('Erro: ' + error.message, 'danger'); return; }
await carregarAdvogados(true);
advQualRender();
}
async function advQualRemover(id) {
if (!_advQualPodeGerir) { toast('Apenas o administrador do escritório pode gerenciar advogados', 'danger'); return; }
if (!confirm('Remover este advogado? Ele deixará de aparecer nas procurações e contratos.')) return;
const { error } = await sb.from('advogados_qualificacao').delete().eq('id', id);
if (error) { toast('Erro: ' + error.message, 'danger'); return; }
toast('Removido', 'success');
await carregarAdvogados(true);
advQualRender();
}
// ===================================================================
// USUÁRIOS — admin-only (RBAC)
// ===================================================================
// Escopo do escritório do admin logado (multi-tenant): usado pela gestão de usuários.
let _meuEscritorio = null; // { id, nome, plano, max_usuarios }
let _usuariosAtivosCount = 0; // usuários ativos do escritório (pro limite do plano)
/* ===================================================================
CRÉDITO DE IA — saldo do mês e extrato
-------------------------------------------------------------------
O advogado aprova o TRABALHO PESADO pelo assistente (Telegram) vendo preço e
saldo. A conversa do dia a dia (fora do jurídico) NÃO pede autorização — é
barata e cai direto aqui; ele é avisado uma vez, na primeira que usa. Aqui ele
confere DEPOIS: "no que gastei meus R$ 30?". Os números vêm todos do razão
(ia_credito_mov) via RPC — o front não soma nada, só desenha, senão a tela e o
extrato divergem.
=================================================================== */
function _cBRL(v) {
const n = Number(v || 0);
return (n < 0 ? '-R$ ' : 'R$ ') + Math.abs(n).toFixed(2).replace('.', ',');
}
// O extrato traz timestamptz; fmtDate() só sabe data pura (quebraria em
// "28T22:25:42.../07/2026"). Aqui sai "28/07 19:25", no fuso do aparelho.
function _cQuando(iso) {
const d = new Date(iso);
if (isNaN(d.getTime())) return '—';
const p = (n) => String(n).padStart(2, '0');
return `${p(d.getDate())}/${p(d.getMonth() + 1)} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
async function creditoIARender() {
const wrap = document.getElementById('creditoIAWrap');
const card = document.getElementById('cardCreditoIA');
if (!wrap) return;
try {
const [{ data: resumo }, { data: extrato }] = await Promise.all([
sb.rpc('rpc_ia_credito_resumo_mes'),
sb.rpc('rpc_ia_credito_extrato', { p_limite: 30 }),
]);
if (!resumo || resumo.erro) { if (card) card.style.display = 'none'; return; }
const s = resumo.saldo || {};
const incluido = Number(s.incluido_mes || 0);
const consumido = Number(s.consumido_mes || 0);
const saldo = Number(s.saldo_brl || 0);
const comprado = Number(s.saldo_comprado || 0);
// A barra mede o que a frase promete: quanto do crédito DISPONÍVEL do mês
// já foi embora. Medir só consumo/incluído dava a contradição de mostrar
// "32% usado" com o saldo já negativo (quando há recarga ou ajuste).
const base = incluido + Math.max(comprado, 0);
const usado = Math.max(base - saldo, 0);
const pct = base > 0 ? Math.min(100, Math.round((usado / base) * 100)) : (saldo < 0 ? 100 : 0);
const cor = saldo < 0 ? '#DC2626' : pct >= 80 ? '#D97706' : '#0F9D58';
const porTarefa = Array.isArray(resumo.por_tarefa) ? resumo.por_tarefa : [];
const linhasTarefa = porTarefa.length
? porTarefa.map(t => `
Você ainda não consumiu crédito neste mês. Resumo do dia, triagem, agenda, cadastro e o panorama jurídico do assistente não consomem nada. O trabalho pesado (ler os autos, minutar, conversar sobre os autos) é cobrado e pede sua autorização, com o preço na tela; a conversa do dia a dia, fora do jurídico, é cobrada direto — barata e sem interromper.
${_cBRL(incluido)} incluídos no plano este mês · consumido ${_cBRL(consumido)}
${comprado ? ` · recargas/ajustes ${_cBRL(comprado)}` : ''}
${pct}% do crédito do mês usado.
${saldo < 0 ? `
Seu crédito do mês acabou. Nada foi bloqueado — o que passou entra na próxima recarga.
` : ''}
No que você gastou este mês
${linhasTarefa}
Lançamentos
Quando
O quê
Quem
Valor
${linhasExtrato}
O trabalho pesado (ler os autos, minutar, conversar sobre os autos) só roda depois que você autoriza, com o preço e o saldo na tela. A conversa do dia a dia — o que não é do escritório — é barata e entra direto aqui no extrato, sem pedir autorização a cada mensagem; você é avisado uma vez, na primeira que usar.
`;
} catch (e) {
wrap.innerHTML = `
Não consegui carregar o crédito agora.
`;
}
}
async function carregarSecaoUsuariosAdmin() {
try {
const { data: { user } } = await sb.auth.getUser();
if (!user) return;
const { data: meuPerfil } = await sb.from('perfis_usuario').select('role, escritorio_id').eq('user_id', user.id).maybeSingle();
if (!meuPerfil || meuPerfil.role !== 'admin') return; // não-admin: oculto
_meuEscritorio = null;
if (meuPerfil.escritorio_id) {
const { data: esc } = await sb.from('escritorios')
.select('id, nome, plano, max_usuarios, billing_status, plano_expira_em').eq('id', meuPerfil.escritorio_id).maybeSingle();
_meuEscritorio = esc || null;
}
document.getElementById('cardUsuariosAdmin').style.display = '';
document.getElementById('btnNovoUsuario').onclick = () => modalUsuario(null);
renderTabelaUsuarios();
const cardPlano = document.getElementById('cardPlanoAdmin');
if (cardPlano) { cardPlano.style.display = ''; renderPlanoAdmin(); }
} catch (e) { console.warn('carregarSecaoUsuariosAdmin:', e?.message); }
}
// ===================================================================
// PAINEL DE SAÚDE DO SISTEMA (só admin do escritório OU owner)
// -------------------------------------------------------------------
// Infere a saúde da "máquina" (watchers LOCAIS: captação de publicações,
// protocolo, íntegra) pela FRESCURA dos dados que esses processos gravam no
// banco — sem depender de mexer nos daemons. Tudo vem de UMA rpc
// (rpc_saude_sistema, SECURITY DEFINER, gate interno + visão cross-tenant).
// custo/lucro de IA só vem preenchido pro owner (gate no servidor) e o front
// só renderiza esse trecho quando ehOwner() (dupla trava).
// ===================================================================
// "há X" por extenso a partir de um timestamp (ou null).
function _saudeHa(iso) {
if (!iso) return null;
const t = new Date(iso).getTime();
if (isNaN(t)) return null;
const seg = Math.floor((Date.now() - t) / 1000);
if (seg < 60) return 'há instantes';
const min = Math.floor(seg / 60);
if (min < 60) return `há ${min} min`;
const h = Math.floor(min / 60);
if (h < 24) return `há ${h} h${h === 1 ? '' : ''}`;
const d = Math.floor(h / 24);
if (d < 30) return `há ${d} dia${d === 1 ? '' : 's'}`;
const meses = Math.floor(d / 30);
return `há ${meses} ${meses === 1 ? 'mês' : 'meses'}`;
}
// Idade em horas (Infinity se não houver evento).
function _saudeHoras(iso) {
if (!iso) return Infinity;
const t = new Date(iso).getTime();
if (isNaN(t)) return Infinity;
return (Date.now() - t) / 3600000;
}
// Mapeia idade -> nível (verde/amarelo/vermelho) por limiares em horas.
function _saudeNivel(horas, amareloH, vermelhoH) {
if (horas <= amareloH) return 'ok';
if (horas <= vermelhoH) return 'warn';
return 'down';
}
const _SAUDE_CORES = {
ok: { cor: '#065F46', bg: 'rgba(16,185,129,.12)', pt: '🟢', label: 'OK' },
warn: { cor: '#92400E', bg: 'rgba(245,158,11,.14)', pt: '🟡', label: 'Atenção' },
down: { cor: '#991B1B', bg: 'rgba(239,68,68,.12)', pt: '🔴', label: 'Parado' },
};
// Card de um componente. nivel: ok|warn|down. obs: string explicando "parado".
function _saudeCard(titulo, nivel, principal, linhas, obs) {
const c = _SAUDE_CORES[nivel] || _SAUDE_CORES.ok;
const extras = (linhas || []).filter(Boolean)
.map(l => `
${d?.erro === 'sem_permissao' ? 'Sem permissão para ver a saúde do sistema.' : 'Não foi possível carregar agora.'}
`;
return;
}
const pub = d.publicacoes || {}, pro = d.protocolo || {}, intg = d.integra || {}, ia = d.ia || {}, ag = d.agenda || {};
const hb = d.heartbeat || {};
const cards = [];
let captacaoDown = false; // sinaliza o banner vermelho de topo (captação é o produto crítico)
let analiseDown = false; // idem p/ a ANÁLISE da IA (publicações vinculadas sem virar prazo)
// ---- Captação de publicações (DJEN) — roda ~1x/dia (dia útil).
// Verde até 26h; amarelo até 50h (fim de semana); vermelho além.
{
const horas = _saudeHoras(pub.ultima_captura);
const dia = new Date().getDay(); // 0=dom, 6=sáb
const fimDeSemana = (dia === 0 || dia === 6);
// A captação roda ~07h em dia útil (tarefa "JuridicoPro Publicacoes"). É o produto
// MAIS importante: num DIA ÚTIL, passar de ~26h sem captura = perdeu a do dia →
// VERMELHO (pode perder prazo). No fim de semana o DJEN não publica → tolera mais
// (amarelo até 56h, vermelho além) pra não dar falso alarme de sábado/domingo.
const nivel = fimDeSemana ? _saudeNivel(horas, 56, 80) : _saudeNivel(horas, 20, 26);
const ha = _saudeHa(pub.ultima_captura) || 'nunca';
if (nivel === 'down') captacaoDown = true;
cards.push(_saudeCard(
`${ic('globe')} Captação de publicações`,
nivel,
`Última captura ${ha}`,
[
`${pub.qtd_hoje || 0} hoje · ${pub.qtd_24h || 0} nas últimas 24h`,
`${pub.total || 0} publicações no total`,
],
'AÇÃO NECESSÁRIA: a captação automática não rodou. Abra o Chrome de automação (:9222) — rode o iniciar_iuria.bat e faça login nos tribunais. Sem captação, novas intimações/prazos NÃO entram — você pode PERDER PRAZOS. É o coração do sistema.'
));
}
// ---- Análise da IA das publicações — detecta "ANÁLISE TRAVADA".
// Sinal: publicações VINCULADAS a processo, ainda NÃO analisadas (ia_analisada_em
// null) e capturadas há +6h/+12h. Com a análise acoplada à captação (3x/dia), nada
// fica >6h sem analisar A NÃO SER que a análise esteja falhando (foi o apagão de 6
// dias que passou em silêncio, pois o painel só olhava a CAPTAÇÃO). Escopo por RLS =
// escritório do usuário (consulta direta, não passa pela rpc_saude_sistema).
{
const desde45 = new Date(Date.now() - 45 * 864e5).toISOString().slice(0, 10);
const h6 = new Date(Date.now() - 6 * 36e5).toISOString();
const h12 = new Date(Date.now() - 12 * 36e5).toISOString();
const baseQ = () => sb.from('publicacoes').select('id', { count: 'exact', head: true })
.not('processo_id', 'is', null).is('ia_analisada_em', null).is('arquivada_em', null)
.gte('data_disponibilizacao', desde45);
let pend6 = 0, pend12 = 0, ultAnalise = null;
try {
const r6 = await baseQ().lt('created_at', h6); pend6 = r6.count || 0;
const r12 = await baseQ().lt('created_at', h12); pend12 = r12.count || 0;
const ru = await sb.from('publicacoes').select('ia_analisada_em')
.not('ia_analisada_em', 'is', null).order('ia_analisada_em', { ascending: false })
.limit(1).maybeSingle();
ultAnalise = ru.data?.ia_analisada_em || null;
} catch (_) { /* best-effort: sem esse card em caso de erro de leitura */ }
let nivel = 'ok';
if (pend6 > 0) nivel = 'warn';
if (pend12 > 0) { nivel = 'down'; analiseDown = true; }
const ha = _saudeHa(ultAnalise) || 'sem registro';
cards.push(_saudeCard(
`${ic('sparkles')} Análise das publicações (IA)`,
nivel,
pend6 > 0 ? `${pend6} publicação(ões) esperando análise` : 'Em dia',
[
`Última análise ${ha}`,
pend6 > 0
? `${pend6} vinculada(s) sem análise há +6h${pend12 > 0 ? ` · ${pend12} há +12h` : ''}`
: 'Nenhuma publicação vinculada esperando análise',
],
analiseDown
? 'AÇÃO NECESSÁRIA: a análise da IA travou — há publicações vinculadas há +12h que ainda NÃO viraram prazo. Rode a tarefa "JuridicoPro Publicacoes" (captura + analisa) e confira o analise.log. Sem análise, os PRAZOS não entram na agenda.'
: ''
));
}
// ---- Protocolo (fila app → autopets locais).
// "Saúde" da fila: se há itens aprovados/aguardando-PDF parados, ou erros.
{
const aprovado = pro.aprovado || 0, agPdf = pro.aguardando_pdf || 0;
const agAss = pro.aguardando_assinatura || 0, erro = pro.erro || 0;
const emFila = aprovado + agPdf;
// nível pela frescura do último protocolado (a máquina "anda"?) — só warn
// se há trabalho preso (em fila/erro). Sem nada na fila = verde.
const horasUlt = _saudeHoras(pro.ultimo_protocolado);
let nivel = 'ok';
if (erro > 0) nivel = 'warn';
if (emFila > 0 && horasUlt > 26) nivel = 'warn';
if (emFila > 0 && horasUlt > 72) nivel = 'down';
const ha = _saudeHa(pro.ultimo_protocolado) || 'nunca';
cards.push(_saudeCard(
`${ic('scroll')} Protocolo`,
nivel,
`Último protocolado ${ha}`,
[
`${aprovado} aprovado(s) · ${agPdf} aguardando PDF · ${agAss} aguardando assinatura`,
`${pro.protocolado || 0} protocolado(s) no total · ${pro.protocolado_hoje || 0} hoje${erro ? ` · ${erro} com erro` : ''}`,
],
emFila > 0
? 'Há peça(s) na fila sem andar — confira watcher_pdf/watcher_protocolo e o login do tribunal.'
: (erro > 0 ? 'Há item(ns) com erro na fila — revise o motivo na fila de protocolo.' : '')
));
}
// ---- Íntegra (baixa de autos).
// Atividade rara/sob demanda: verde se houve baixa nos últimos 7 dias OU
// não há nada na fila. Vermelho só se há pedido pendente e nenhuma baixa há muito.
{
const horas = _saudeHoras(intg.ultima_baixa);
const naFila = intg.na_fila || 0;
let nivel = 'ok';
if (naFila > 0 && horas > 6) nivel = 'warn';
if (naFila > 0 && horas > 24) nivel = 'down';
const ha = _saudeHa(intg.ultima_baixa) || 'nunca';
cards.push(_saudeCard(
`${ic('books')} Íntegra dos autos`,
nivel,
`Última baixa ${ha}`,
[
`${naFila} pedido(s) na fila`,
`${intg.total_docs || 0} documentos indexados`,
],
'Pedido de íntegra parado na fila — confira watcher_integra e o login dos Autos Digitais.'
));
}
// ---- IA (uso do dia). Informativo: nível pela atividade, sem alarme.
{
const chamadas = ia.chamadas_hoje || 0;
const ha = _saudeHa(ia.ultima_chamada) || 'sem registro';
const linhas = [`Última chamada ${ha}`];
// custo/lucro SÓ pro owner (e o servidor só preenche pro owner — dupla trava)
if (ehOwner() && (ia.custo_brl_hoje != null || ia.preco_brl_hoje != null)) {
const custo = Number(ia.custo_brl_hoje || 0);
const preco = Number(ia.preco_brl_hoje || 0);
const lucro = preco - custo;
linhas.push(`Custo real hoje: ${fmtBRL(custo)} · Cobrado: ${fmtBRL(preco)} · Lucro: ${fmtBRL(lucro)}`);
}
cards.push(_saudeCard(
`${ic('sparkles')} Inteligência Artificial`,
'ok',
`${chamadas} chamada(s) hoje`,
linhas,
''
));
}
// ---- Agenda (prazos). Vermelho se há vencidos; amarelo se vence(m) hoje.
{
const venc = ag.vencidos_hoje || 0, hoje = ag.vencem_hoje || 0;
let nivel = 'ok';
if (hoje > 0) nivel = 'warn';
if (venc > 0) nivel = 'down';
cards.push(_saudeCard(
`${ic('calendar')} Prazos`,
nivel,
venc > 0 ? `${venc} prazo(s) vencido(s)` : (hoje > 0 ? `${hoje} vence(m) hoje` : 'Nenhum prazo crítico'),
[`${hoje} vence(m) hoje · ${venc} já vencido(s) em aberto`],
venc > 0 ? 'Há prazo(s) em aberto já vencido(s) — verifique na Agenda.' : ''
));
}
// ---- Heartbeat (opcional): só aparece se algum watcher já registrou.
let hbHtml = '';
const hbKeys = Object.keys(hb);
if (hbKeys.length) {
hbHtml = `
🔴CAPTAÇÃO DE PUBLICAÇÕES PARADA — a busca automática no DJEN não rodou. Abra o Chrome de automação (:9222) e verifique (rode o iniciar_iuria.bat e faça login nos tribunais). Enquanto estiver parada, novas intimações e prazos NÃO entram no sistema.
` : '';
const bannerAnalise = analiseDown ? `
🔴ANÁLISE DAS PUBLICAÇÕES PARADA — há publicação(ões) vinculada(s) sem análise há mais de 12h, então novos PRAZOS não estão entrando na agenda. Rode a tarefa JuridicoPro Publicacoes (captura + analisa) e confira o analise.log.
A cobrança é por advogado captando — cada inscrição na OAB cadastrada em Configurações → Captação de publicações (DJEN). Usuários do escritório (equipe, estagiários) não são cobrados por login. Acima de 20 advogados captando, a proposta é sob consulta. Pagamento recorrente mensal via Asaas (PIX, boleto ou cartão). Sem fidelidade — cancele quando quiser. O upgrade libera mais advogados captando e mais recursos assim que o pagamento for confirmado.
`;
_planoOabsRender(); // avisa (sem bloquear) se passou do limite do plano
}
window.renderPlanoAdmin = renderPlanoAdmin;
// ===== Limite por ADVOGADO CAPTANDO (OAB) — unidade de cobrança =====
// Desde 27/07/2026 o plano é medido por inscrição na OAB cadastrada na captação
// do DJEN, não por login. Este bloco só MEDE E AVISA — não bloqueia nada. Quem
// já era cliente antes da mudança está marcado como `cortesia` no banco
// (escritorios.limite_cortesia) e nunca vê aviso nenhum.
// Se a RPC não existir no ambiente (ex.: banco ainda sem a migration), tudo
// devolve null e a interface simplesmente não mostra nada — nunca quebra.
let _oabsStatusCache = null;
async function _oabsCapturaStatus(forcar) {
if (_oabsStatusCache && !forcar) return _oabsStatusCache;
try {
const { data, error } = await sb.rpc('rpc_oabs_captura_status');
if (error) return null;
_oabsStatusCache = (Array.isArray(data) ? data[0] : data) || null;
return _oabsStatusCache;
} catch (e) { return null; }
}
// Devolve o HTML do contador/aviso. String vazia = não há nada a dizer
// (cortesia, plano sem teto, ou banco sem a migration).
function _oabsAvisoHtml(s) {
if (!s || s.cortesia || s.limite == null) return '';
const n = Number(s.oabs_ativas || 0), lim = Number(s.limite);
const planoLabel = ({ essencial:'Essencial', profissional:'Profissional', premium:'Premium' })[s.plano] || (s.plano || '');
if (n <= lim) {
return `
${ic('users')} ${n} de ${lim} advogado(s) captando no plano ${escape(planoLabel)} — cada inscrição na OAB cadastrada na captação do DJEN.
${n} advogados captando — o plano ${escape(planoLabel)} inclui ${lim}.
Nada foi bloqueado: a captação do DJEN continua rodando para todos. Só um aviso de que o uso passou do que o plano contempla — quando puder, ${acao}.
`;
}
async function _planoOabsRender() {
const w = document.getElementById('planoOabsWrap');
if (!w) return;
const s = await _oabsCapturaStatus(true);
w.innerHTML = _oabsAvisoHtml(s);
}
// Editar o nome do escritório (admin/supervisor). RLS já permite o UPDATE
// em escritorios.nome só pra esses papéis do próprio tenant.
async function editarNomeEscritorio() {
const e = _meuEscritorio;
if (!e || !e.id) { toast('Escritório não encontrado.', 'danger'); return; }
const atual = e.nome || '';
const novo = (prompt('Nome do escritório:', atual) || '').trim();
if (!novo || novo === atual) return;
if (novo.length < 2) { toast('Nome muito curto.', 'danger'); return; }
try {
const { error } = await sb.from('escritorios').update({ nome: novo }).eq('id', e.id);
if (error) { toast('Não foi possível salvar o nome: ' + error.message, 'danger'); return; }
_meuEscritorio.nome = novo;
renderPlanoAdmin();
if (typeof renderTabelaUsuarios === 'function') { try { renderTabelaUsuarios(); } catch (_) {} }
toast('Nome do escritório atualizado.', 'success');
} catch (err) {
toast('Erro ao salvar: ' + (err?.message || err), 'danger');
}
}
window.editarNomeEscritorio = editarNomeEscritorio;
// Contratação do plano pago. ANTES de ir ao checkout do Asaas exige o ACEITE
// EXPLÍCITO do Contrato de Adesão (separado do consentimento de Política/Termos
// do cadastro). É o ponto ÚNICO que cobre todas as origens de contratação
// (card "Plano & Cobrança" e o paywall de trial vencido), pois ambas chamam
// assinarPlano. A versão aceita é enviada à edge, que grava
// aceite_contrato_em/_versao server-side no mesmo ato (caminho confiável).
function assinarPlano(planoKey) {
const nomes = { essencial:'Essencial (R$ 97/mês)', profissional:'Profissional (R$ 197/mês)', premium:'Premium (R$ 397/mês)' };
const ver = (typeof CONTRATO_ADESAO_VERSAO !== 'undefined' ? CONTRATO_ADESAO_VERSAO : '');
const aceiteCurto = (typeof CONTRATO_ADESAO_ACEITE_CURTO !== 'undefined' ? CONTRATO_ADESAO_ACEITE_CURTO : '');
document.getElementById('aceiteAdesaoOverlay')?.remove();
const ov = document.createElement('div');
ov.id = 'aceiteAdesaoOverlay';
// z-index acima do billingWall (2147483646) p/ funcionar também no paywall.
ov.style.cssText = 'position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;background:rgba(2,6,23,.6);padding:20px 12px;overflow-y:auto';
ov.innerHTML = `
Contratar plano ${escape(nomes[planoKey] || planoKey)}
Você será levado ao checkout do Asaas pra confirmar o pagamento (PIX, boleto ou cartão). O plano é ativado assim que o pagamento for aprovado.
${escape(aceiteCurto)}
${ver ? `
Versão do contrato: ${escape(ver)} · rascunho a revisar.
` : ''}
`;
document.body.appendChild(ov);
const chk = ov.querySelector('#aceiteAdesaoChk');
const btn = ov.querySelector('#aceiteConfirmar');
const fechar = () => ov.remove();
chk.onchange = () => {
btn.disabled = !chk.checked;
btn.style.cursor = chk.checked ? 'pointer' : 'not-allowed';
btn.style.opacity = chk.checked ? '1' : '.5';
};
ov.querySelector('#aceiteAdesaoLink').onclick = (e) => { e.preventDefault(); abrirDocLegal('adesao'); };
ov.querySelector('#aceiteFechar').onclick = fechar;
ov.querySelector('#aceiteCancelar').onclick = fechar;
ov.addEventListener('click', (e) => { if (e.target === ov) fechar(); });
btn.onclick = async () => {
if (!chk.checked) return;
btn.disabled = true; btn.style.opacity = '.6'; btn.textContent = 'Aguarde…';
await _prosseguirAssinatura(planoKey, ver);
fechar();
};
}
window.assinarPlano = assinarPlano;
// Só roda DEPOIS do aceite explícito do Contrato de Adesão. Manda a versão
// aceita p/ a edge gravar o aceite server-side e abrir o checkout do Asaas.
async function _prosseguirAssinatura(planoKey, contratoVersao) {
try {
const backUrl = location.origin + location.pathname;
const { data, error } = await sb.functions.invoke('assinar-plano', { body: { plano: planoKey, back_url: backUrl, contrato_aceito: true, contrato_versao: contratoVersao || null } });
let resp = data;
if (error) { try { resp = await error.context.json(); } catch(_) { resp = null; } }
if (!resp || !resp.ok || !resp.init_point) {
toast((resp && resp.error) ? resp.error : 'Não foi possível iniciar a assinatura.', 'danger');
return;
}
window.location.href = resp.init_point; // checkout do Asaas
} catch (e) {
toast('Erro: ' + (e.message || e), 'danger');
}
}
/* ==================================================================
RECARGA DE CRÉDITO DE IA (compra avulsa)
------------------------------------------------------------------
Acabou o crédito do mês, o advogado compra mais. Os pacotes vêm do
BANCO (rpc_ia_credito_pacotes) — o George muda valor, ativa e
desativa por UPDATE, sem deploy e sem mexer aqui.
Isto NÃO é assinatura: a edge comprar-credito cria uma cobrança
avulsa com externalReference 'CRED|...', e o webhook trata esse
prefixo num caminho separado do da mensalidade. Comprar crédito
nunca renova plano.
================================================================== */
async function abrirRecargaCredito() {
let pacotes = [], saldo = null;
try {
const [p, s] = await Promise.all([
sb.rpc('rpc_ia_credito_pacotes'),
sb.rpc('rpc_ia_credito_saldo'),
]);
pacotes = Array.isArray(p.data) ? p.data : [];
saldo = (s.data && !s.data.erro) ? s.data : null;
} catch (e) { /* segue com o que deu */ }
const fmt = (n) => 'R$ ' + Number(n || 0).toFixed(2).replace('.', ',');
if (!pacotes.length) {
openModal('Recarregar crédito de IA',
`
Não há pacote de recarga disponível no momento. Fale com o suporte.
Seu saldo agora: ${fmt(saldo.saldo_brl)}(${fmt(saldo.saldo_incluido)} do plano neste mês + ${fmt(saldo.saldo_comprado)} comprado)
`
: '';
const body = `
${linhaSaldo}
${cards}
Compra avulsa: não é mensalidade e não altera o seu plano.
O crédito comprado não vence e fica somado ao crédito do mês.
Você escolhe PIX, boleto ou cartão na página de pagamento — o crédito entra assim que o pagamento for confirmado.
`;
openModal('Recarregar crédito de IA', body,
``);
}
window.abrirRecargaCredito = abrirRecargaCredito;
async function comprarCreditoIA(pacote, btn) {
if (btn) { btn.disabled = true; btn.style.opacity = '.6'; btn.style.cursor = 'wait'; }
try {
const backUrl = location.origin + location.pathname;
const { data, error } = await sb.functions.invoke('comprar-credito', { body: { pacote, back_url: backUrl } });
let resp = data;
if (error) { try { resp = await error.context.json(); } catch (_) { resp = null; } }
if (!resp || !resp.ok || !resp.init_point) {
toast((resp && resp.error) ? resp.error : 'Não foi possível abrir o pagamento da recarga.', 'danger');
if (btn) { btn.disabled = false; btn.style.opacity = '1'; btn.style.cursor = 'pointer'; }
return;
}
window.location.href = resp.init_point; // checkout do Asaas
} catch (e) {
toast('Erro: ' + (e.message || e), 'danger');
if (btn) { btn.disabled = false; btn.style.opacity = '1'; btn.style.cursor = 'pointer'; }
}
}
window.comprarCreditoIA = comprarCreditoIA;
// O assistente manda o link '.../#credito' quando o saldo está baixo. Chegando
// por ele, a tela de recarga abre sozinha (e o hash sai da barra de endereço).
function _abrirRecargaPeloHash() {
if (String(location.hash || '').toLowerCase() !== '#credito') return;
try { history.replaceState(null, '', location.pathname + location.search); } catch (_) {}
setTimeout(() => { try { abrirRecargaCredito(); } catch (_) {} }, 800);
}
window.addEventListener('hashchange', _abrirRecargaPeloHash);
// Gate de cobrança: banner fixo no topo quando o escritório está com acesso
// expirado (trial vencido / pagamento pendente). Escritório isento (fundador)
// nunca vê. Soft-block: não tranca os dados, só avisa e leva pro upgrade.
// ===== Teto de custo de IA (trial) — pedido do George 24/07 =====
// Bloqueia a GERAÇÃO por IA quando o escritório passou do teto. Limite nominal
// R$30 + R$5 de folga (não corta uma peça no meio) → bloqueio real em R$35.
// Escritório 'active' (pago/dono) NUNCA bloqueia. A conta vem do banco
// (rpc ia_limite_status), pelo escritório do PRÓPRIO login — não dá pra falsear.
async function _iaGate() {
try {
const { data, error } = await sb.rpc('ia_limite_status');
const s = Array.isArray(data) ? data[0] : data;
if (error || !s || !s.bloqueado) return false;
_iaLimiteModal(s);
return true;
} catch (e) { return false; } // erro de rede não pode travar o trabalho
}
function _iaLimiteModal(s) {
const fmt = (n) => 'R$ ' + Number(n || 0).toFixed(2).replace('.', ',');
// 09/09/2026 — A TELA MENTIA PARA QUEM JA PAGA.
// Ela dizia "periodo de testes" e oferecia os tres planos SEMPRE, inclusive
// para escritorio com billing ativo. O Dr. Alessandro bateu no teto PESSOAL
// da conta dele (perfis_usuario.ia_limite_brl = 30) com o AJF em dia, leu
// "contrate um plano" e teria contratado um plano que ele JA TINHA — sem
// destravar nada. ia_limite_status ja devolve `billing`, que diz qual regra
// bloqueou: 'pessoal' = teto do login; qualquer outra coisa = trial do
// escritorio. Agora a tela usa isso e fala a verdade nos dois casos.
const pessoal = String((s && s.billing) || '') === 'pessoal';
const usou = fmt(s && s.custo_brl), teto = fmt(s && s.limite);
if (pessoal) {
const body = `
🔒
Limite de uso de IA atingido
Você já usou ${usou} em recursos de inteligência artificial, e o limite da sua conta é de ${teto}.
Este limite é da sua conta de usuário — não é do plano do escritório. Contratar ou trocar de plano não altera este teto.
Peça ao administrador do escritório para aumentá-lo, ou fale com o suporte do IURIA.
`;
const footer = ``;
try { openModal('Limite de uso de IA', body, footer); }
catch (e) { alert('Limite de uso de IA da sua conta atingido (' + usou + ' de ' + teto + '). Este limite e da sua conta, nao do plano do escritorio: peca ao administrador para aumenta-lo.'); }
return;
}
const planosBtns = [['essencial', 'Essencial', 'R$ 97'], ['profissional', 'Profissional', 'R$ 197'], ['premium', 'Premium', 'R$ 397']]
.map(([k, n, p]) => ``).join(' ');
const body = `
🔒
Seu limite de teste foi atingido
Você já usou ${usou} em recursos de inteligência artificial no período de testes (limite de ${teto}).
Para liberar todas as funcionalidades do IURIA — petições, análises e o assistente — contrate um plano ou fale com o suporte.
${planosBtns}
`;
const footer = ``;
try { openModal('Limite de teste atingido', body, footer); }
catch (e) { alert('Seu limite de teste do IURIA foi atingido. Contrate um plano ou fale com o suporte para liberar todas as funcionalidades.'); }
}
async function verificarBillingEscritorio() {
try {
document.getElementById('billingBanner')?.remove();
document.getElementById('billingWall')?.remove();
const { data: { user } } = await sb.auth.getUser();
if (!user) return;
const { data: perfil } = await sb.from('perfis_usuario').select('role, escritorio_id').eq('user_id', user.id).maybeSingle();
if (!perfil?.escritorio_id) return;
const { data: esc } = await sb.from('escritorios').select('nome, billing_status, billing_isento, plano_expira_em, trial_renovado_em').eq('id', perfil.escritorio_id).maybeSingle();
if (!esc || esc.billing_isento) return;
const ehAdmin = perfil.role === 'admin';
const st = esc.billing_status;
const venceu = esc.plano_expira_em && new Date(esc.plano_expira_em) < new Date();
const jaRenovou = !!esc.trial_renovado_em; // já usou a repescagem (+15) uma vez
// HARD-LOCKOUT: só quando o teste JÁ renovado venceu (trial_renovado_em != null),
// ou o servidor já marcou trial_expirado/bloqueado. O 1º vencimento NÃO bloqueia —
// o cron das 6h renova +15 (repescagem) automaticamente, uma única vez.
const trialVencido = st === 'trial_expirado' || (st === 'trial' && venceu && jaRenovou);
if (st === 'bloqueado' || trialVencido) {
const planos = ehAdmin
? `
${trialVencido ? 'Seu teste grátis terminou' : 'Acesso suspenso'}
${trialVencido
? `Os 15 dias grátis do escritório ${escape(esc.nome || '')} terminaram. Escolha um plano pra continuar usando o IURIA — nada foi apagado, é só assinar.`
: `A assinatura do escritório ${escape(esc.nome || '')} está pendente. Assine um plano pra reativar o IURIA — nada foi apagado, seus dados voltam assim que o pagamento for confirmado.`}
${planos}
`;
document.body.appendChild(w);
return;
}
// ENTRE-VENCIMENTOS: o 1º teste venceu e o servidor ainda vai renovar (+15).
// Banner amigável de cortesia — NÃO bloqueia (a regra do George: dá mais 15 dias).
if (st === 'trial' && venceu && !jaRenovou) {
const acaoR = ehAdmin
? ``
: '';
const br = document.createElement('div');
br.id = 'billingBanner';
br.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:99999;background:#0D9488;color:#fff;padding:9px 16px;display:flex;gap:14px;align-items:center;justify-content:center;font-size:13px;flex-wrap:wrap;box-shadow:0 2px 10px rgba(0,0,0,.25)';
br.innerHTML = `${ic('hourglass')} Seu teste terminou — a IURIA liberou mais 15 dias de cortesia. Aproveite para assinar e não perder nada. ${acaoR}`;
document.body.appendChild(br);
return;
}
// LEMBRETE: trial terminando (faltam ≤ 5 dias) — banner suave, não bloqueia.
if (st === 'trial' && !venceu && esc.plano_expira_em) {
const dias = Math.ceil((new Date(esc.plano_expira_em) - new Date()) / 86400000);
if (dias >= 0 && dias <= 5) {
const acaoT = ehAdmin
? ``
: '';
const bt = document.createElement('div');
bt.id = 'billingBanner';
bt.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:99999;background:#D97706;color:#fff;padding:9px 16px;display:flex;gap:14px;align-items:center;justify-content:center;font-size:13px;flex-wrap:wrap;box-shadow:0 2px 10px rgba(0,0,0,.25)';
const frase = jaRenovou
? `${ic('hourglass')} Última renovação: faltam ${dias} dia${dias === 1 ? '' : 's'} do seu teste. Assine para manter o acesso.`
: `${ic('hourglass')} Seu teste grátis termina em ${dias} dia${dias === 1 ? '' : 's'}. Assine um plano pra não perder o acesso.`;
bt.innerHTML = `${frase} ${acaoT}`;
document.body.appendChild(bt);
}
return;
}
// SOFT: banner de aviso (ainda dentro da tolerância antes do bloqueio).
const aviso = st === 'past_due' || (st === 'canceled' && venceu);
if (!aviso) return;
const msg = 'A assinatura do seu escritório está pendente de pagamento.';
const acao = ehAdmin
? ``
: `Fale com o administrador do escritório pra reativar.`;
const b = document.createElement('div');
b.id = 'billingBanner';
b.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:99999;background:#B91C1C;color:#fff;padding:9px 16px;display:flex;gap:14px;align-items:center;justify-content:center;font-size:13px;flex-wrap:wrap;box-shadow:0 2px 10px rgba(0,0,0,.25)';
b.innerHTML = `⚠ ${msg} Assine um plano pra continuar usando o IURIA. ${acao}`;
document.body.appendChild(b);
} catch (e) { console.warn('billing check:', e?.message); }
}
window.verificarBillingEscritorio = verificarBillingEscritorio;
function irParaPlanos() {
const nav = document.querySelector('.nav-btn[data-view="config"]');
if (nav) nav.click(); else renderView('config');
setTimeout(() => document.getElementById('cardPlanoAdmin')?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 400);
}
window.irParaPlanos = irParaPlanos;
async function renderTabelaUsuarios() {
const wrap = document.getElementById('tbUsuariosWrap');
if (!wrap) return;
let q = sb.from('perfis_usuario')
.select('id, nome, email, role, advogado_responsavel_id, oab, oab_uf, ativo, criado_em, escritorio_id')
.order('criado_em', { ascending: true });
if (_meuEscritorio?.id) q = q.eq('escritorio_id', _meuEscritorio.id);
const { data: perfis, error } = await q;
if (error) { wrap.innerHTML = `
Erro: ${escape(error.message)}
`; return; }
// Contagem de ativos. ATENÇÃO: desde 27/07/2026 o plano NÃO é medido por
// usuário/login e sim por ADVOGADO CAPTANDO (OAB na captação do DJEN). Por
// isso o botão "Novo usuário" não é mais travado por max_usuarios — o teto de
// assentos deixou de existir como regra de negócio. A coluna max_usuarios
// continua no banco (legado / webhook do Asaas) mas não bloqueia mais nada.
_usuariosAtivosCount = (perfis || []).filter(p => p.ativo).length;
const btnNovo = document.getElementById('btnNovoUsuario');
if (btnNovo) { btnNovo.disabled = false; btnNovo.title = ''; btnNovo.style.opacity = ''; }
// Header do escritório (nome + plano + usuários ativos, sem teto)
let header = '';
if (_meuEscritorio) {
const planoLabel = ({ essencial: 'Essencial', profissional: 'Profissional', premium: 'Premium' })[_meuEscritorio.plano] || (_meuEscritorio.plano || '—');
header = `
`;
}
async function modalUsuario(perfilId) {
const novo = !perfilId;
// Sem trava de assentos: o plano é medido por advogado captando (OAB no DJEN),
// não por usuário. Equipe, estagiários e secretaria entram sem custo por login.
// (Não havia trava no banco — o bloqueio era só aqui no front.)
let p = { nome:'', email:'', role:'advogado', advogado_responsavel_id:null, oab:'', oab_uf:'' };
if (!novo) {
const { data } = await sb.from('perfis_usuario').select('*').eq('id', perfilId).single();
if (data) p = data;
}
// Carrega lista de advogados DO ESCRITÓRIO pra dropdown de vínculo de estagiário
let advQ = sb.from('perfis_usuario').select('id, nome').eq('role', 'advogado').eq('ativo', true);
if (_meuEscritorio?.id) advQ = advQ.eq('escritorio_id', _meuEscritorio.id);
const { data: advs } = await advQ;
const advOpts = (advs||[]).map(a => ``).join('');
const html = `
${novo ? `
` : `
Email: ${escape(p.email||'')} (não editável; pra trocar senha use o botão Reset)
`}
`;
// Usa openModal (modalConfirm não existe — chamada fantasma de versão antiga).
// Botão "Salvar" no footer dispara a lógica de submit; "Cancelar" fecha o modal.
const footer = `
`;
openModal(novo ? 'Novo usuário' : 'Editar usuário', html, footer);
// Mostra/esconde vínculo conforme role (precisa ser depois do openModal pq o DOM só existe depois)
document.getElementById('uRole').addEventListener('change', e => {
document.getElementById('lblVinculo').style.display = (e.target.value === 'estagiario') ? 'block' : 'none';
});
// Handler do botão Salvar
document.getElementById('btnSalvarUsuario').onclick = async () => {
const btn = document.getElementById('btnSalvarUsuario');
const nome = document.getElementById('uNome').value.trim();
const role = document.getElementById('uRole').value;
const vinculo = document.getElementById('uVinculo')?.value || null;
const oab = document.getElementById('uOab').value.trim();
const oabUf = document.getElementById('uOabUf').value.trim().toUpperCase();
if (!nome) { toast('Nome obrigatório', 'danger'); return; }
const body = { nome, role, advogado_responsavel_id: role === 'estagiario' ? vinculo : null, oab, oab_uf: oabUf };
if (novo) {
const email = document.getElementById('uEmail').value.trim();
const senha = document.getElementById('uSenha').value;
if (!email || !senha) { toast('Email e senha obrigatórios', 'danger'); return; }
if (senha.length < 6) { toast('Senha mínima 6 chars', 'danger'); return; }
body.acao = 'criar'; body.email = email; body.senha = senha;
} else {
body.acao = 'editar'; body.perfil_id = perfilId;
}
btn.disabled = true; btn.textContent = 'Salvando...';
try {
const r = await chamarEdgeUsuario(body);
if (r.error) {
toast('Erro: ' + r.error + (r.detail ? ' · ' + r.detail : ''), 'danger');
btn.disabled = false; btn.textContent = novo ? 'Criar usuário' : 'Salvar alterações';
return;
}
toast(novo ? `Usuário criado. Email: ${body.email} / senha: ${body.senha}` : 'Usuário atualizado', 'success');
closeModal();
renderTabelaUsuarios();
} catch (e) {
toast('Erro inesperado: ' + e.message, 'danger');
btn.disabled = false; btn.textContent = novo ? 'Criar usuário' : 'Salvar alterações';
}
};
}
async function toggleAtivoUsuario(perfilId, ativar) {
if (!confirm(ativar ? 'Reativar usuário?' : 'Desativar usuário? Ele perde acesso imediato.')) return;
const r = await chamarEdgeUsuario({ acao: ativar ? 'ativar' : 'desativar', perfil_id: perfilId });
if (r.error) { toast('Erro: ' + r.error, 'danger'); return; }
toast(ativar ? 'Usuário reativado' : 'Usuário desativado', 'success');
renderTabelaUsuarios();
}
async function resetSenhaUsuario(perfilId, nome) {
const nova = prompt(`Nova senha pra ${nome} (mín 6 chars):`);
if (!nova) return;
if (nova.length < 6) { toast('Senha mínima 6 chars', 'danger'); return; }
const r = await chamarEdgeUsuario({ acao: 'reset_senha', perfil_id: perfilId, nova_senha: nova });
if (r.error) { toast('Erro: ' + r.error, 'danger'); return; }
toast(`Senha redefinida pra ${nome}. Avise ele(a) da nova senha.`, 'success');
}
async function chamarEdgeUsuario(body) {
try {
const { data: sess } = await sb.auth.getSession();
const jwt = sess?.session?.access_token;
if (!jwt) return { error: 'Sem sessão ativa' };
const r = await fetch(`${sb.supabaseUrl}/functions/v1/criar-usuario`, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + jwt, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return await r.json();
} catch (e) { return { error: e.message }; }
}
async function importarDoPromad(e) {
const files = Array.from(e.target.files || []);
if (!files.length) return;
// Lê todos os arquivos
const arquivosLidos = [];
for (const file of files) {
try {
const txt = await file.text();
const data = JSON.parse(txt);
arquivosLidos.push({ nome: file.name, data, ts: data._exportedAt || file.name });
} catch (err) {
toast(`Arquivo "${file.name}" inválido — pulando`, 'danger');
}
}
if (!arquivosLidos.length) { toast('Nenhum arquivo válido', 'danger'); return; }
// Ordena pelo timestamp/nome (mais antigo primeiro) — assim parciais mais recentes
// sobrescrevem os antigos no merge
arquivosLidos.sort((a, b) => (a.ts || '').localeCompare(b.ts || ''));
// Merge: clientes únicos por promadId, processos únicos por (clienteId+numero)
const clientesMap = new Map();
const processosMap = new Map();
for (const arq of arquivosLidos) {
const cs = arq.data.clientes || arq.data.parties || [];
const ps = arq.data.processos || [];
for (const c of cs) {
const key = String(c.promadId || c.cpf || c.nome);
// Mescla campos: novos arquivos sobrescrevem só se tiverem valor
const existente = clientesMap.get(key) || {};
const merged = { ...existente };
for (const [k, v] of Object.entries(c)) {
if (v != null && v !== '') merged[k] = v;
}
clientesMap.set(key, merged);
}
for (const p of ps) {
const key = `${p.clienteId || ''}|${p.numero || ''}|${p.promadId || ''}`;
const existente = processosMap.get(key) || {};
const merged = { ...existente };
for (const [k, v] of Object.entries(p)) {
if (v != null && v !== '') merged[k] = v;
}
processosMap.set(key, merged);
}
}
const clientes = [...clientesMap.values()];
const processosEntrada = [...processosMap.values()];
if (!clientes.length) { toast('Nenhum cliente encontrado nos arquivos', 'danger'); return; }
const msg = arquivosLidos.length > 1
? `Mesclar ${arquivosLidos.length} arquivos → ${clientes.length} cliente(s) únicos e ${processosEntrada.length} processo(s) únicos. Clientes existentes (mesmo CPF/ID Promad) serão atualizados. OK?`
: `Importar ${clientes.length} cliente(s) e ${processosEntrada.length} processo(s)? Clientes existentes (mesmo CPF) serão atualizados.`;
if (!confirm(msg)) return;
// Carrega clientes existentes para dedup por CPF ou por promadId
const existentes = await dbGetAll('clientes');
const byCPF = new Map(existentes.filter(c => c.cpf).map(c => [limparDoc(c.cpf), c]));
const byPromadId = new Map(existentes.filter(c => c.promadId).map(c => [String(c.promadId), c]));
// Map de promadId → novo id no app, pra vincular processos
const idMap = new Map();
let importados = 0, atualizados = 0;
for (const c of clientes) {
const normalizado = normalizarClientePromad(c);
// Procura por CPF existente, senão por promadId
const cpfKey = limparDoc(normalizado.cpf);
const promadIdKey = String(normalizado.promadId || '');
let existente = (cpfKey && byCPF.get(cpfKey)) || (promadIdKey && byPromadId.get(promadIdKey));
if (existente) {
const merged = { ...existente, ...normalizado, id: existente.id, atualizadoEm: new Date().toISOString() };
await dbPut('clientes', merged);
idMap.set(promadIdKey, existente.id);
atualizados++;
} else {
normalizado.criadoEm = new Date().toISOString();
normalizado.atualizadoEm = normalizado.criadoEm;
const novoId = await dbAdd('clientes', normalizado);
if (promadIdKey) idMap.set(promadIdKey, novoId);
importados++;
}
}
// Importa processos vinculados
let processosImportados = 0;
for (const p of processosEntrada) {
const pNorm = normalizarProcessoPromad(p);
const clienteIdApp = idMap.get(String(p.clienteId || p.codParte || ''));
if (!clienteIdApp) continue; // sem cliente vinculado, pula
pNorm.clienteId = clienteIdApp;
pNorm.criadoEm = new Date().toISOString();
pNorm.atualizadoEm = pNorm.criadoEm;
await dbAdd('processos', pNorm);
processosImportados++;
}
toast(`✅ ${importados} novo(s), ${atualizados} atualizado(s), ${processosImportados} processo(s)`, 'success');
renderView('dashboard');
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
document.querySelector('[data-view=dashboard]').classList.add('active');
}
function limparDoc(s) { return (s || '').replace(/\D/g, ''); }
// Máscara BR de telefone: (00) 00000-0000 (celular 11 díg.) ou (00) 0000-0000
// (fixo 10 díg.). Tolerante a número incompleto — formata o que houver.
// Guardar mascarado é seguro: consumidores fazem replace(/\D/g,'') antes de usar.
function mascaraTelefoneBR(v) {
let d = String(v == null ? '' : v).replace(/\D/g, '');
if (d.startsWith('55') && d.length > 11) d = d.slice(2); // tira DDI se presente
d = d.slice(0, 11);
if (!d) return '';
if (d.length <= 2) return '(' + d;
const ddd = d.slice(0, 2);
const resto = d.slice(2);
if (resto.length <= 4) return `(${ddd}) ${resto}`;
if (resto.length <= 8) return `(${ddd}) ${resto.slice(0, 4)}-${resto.slice(4)}`; // fixo
return `(${ddd}) ${resto.slice(0, 5)}-${resto.slice(5)}`; // celular
}
function normalizarClientePromad(c) {
// Aceita tanto o formato do scraper quanto formato genérico
return {
promadId: c.promadId || c.codParte || c.id || null,
tipoPessoa: c.tipoPessoa || (c.cnpj ? 'PJ' : 'PF'),
nome: c.nome || c.razaoSocial || '',
nacionalidade: c.nacionalidade || '',
cpf: c.cpf || c.cnpj || '',
rg: c.rg || c.inscricaoEstadual || '',
nascimento: c.nascimento || c.dataNascimento || '',
profissao: c.profissao || '',
escolaridade: c.escolaridade || '',
estadoCivil: c.estadoCivil || '',
email1: c.email1 || c.email || '',
email2: c.email2 || '',
celular: c.celular || c.telefoneCelular || '',
residencial: c.residencial || c.telefoneResidencial || '',
comercial: c.comercial || c.telefoneComercial || '',
fax: c.fax || '',
cep: c.cep || '',
estado: c.estado || '',
uf: c.uf || '',
endereco: c.endereco || c.logradouro || '',
bairro: c.bairro || '',
cidade: c.cidade || '',
nomePai: c.nomePai || '',
nomeMae: c.nomeMae || '',
grupo: c.grupo || c.grupoCliente || '',
perfil: c.perfil || '',
observacao: c.observacao || c.observacoes || ''
};
}
function normalizarProcessoPromad(p) {
return {
promadId: p.promadId || p.codProcesso || p.id || null,
numero: p.numero || p.numeroCnj || '',
areaDireito: p.areaDireito || p.areaAtuacao || p.objetoAcao || '',
natureza: p.natureza || p.assunto || p.objetoAcao || '',
posicao: p.posicao || p.perfilParte || '',
tribunal: p.tribunal || '',
comarca: p.comarca || '',
vara: p.vara || p.localTramite || '',
parteAdversa: p.parteAdversa || p.adverso || '',
advogadoAdverso: p.advogadoAdverso || p.advAdverso || '',
valorCausa: p.valorCausa || '',
dataDistribuicao: p.dataDistribuicao || '',
status: normalizarStatus(statusProc(p)),
fase: p.fase || '',
prognostico: p.prognostico || '',
// Campos adicionais específicos do Promad
pasta: p.pasta || '',
responsavel: p.responsavel || '',
objetoAcao: p.objetoAcao || '',
localizador: p.localizador || '',
apensos: p.apensos || '',
observacao: p.observacao || ''
};
}
function normalizarStatus(s) {
if (!s) return 'Em andamento';
const v = String(s).toLowerCase().trim();
// "A distribuir" = pendente de protocolo. NÃO confundir com "Distribuído" (já
// protocolado): casa só a forma pendente (começa com "a distribuir").
if (v === 'a distribuir' || v.startsWith('a distribuir')) return 'A distribuir';
// ORDEM IMPORTA (09/09): 'inativ' TEM de vir antes de 'ativ' — "Inativo" contém
// a substring "ativ" e caía em "Em andamento". O ramo 'inativ' embaixo era
// código morto. Mesma ordem da fn normalizar_status_processo() no banco.
if (v.includes('encer') || v.includes('inativ')) return 'Encerrado';
if (v.includes('arquiv')) return 'Arquivado';
if (v.includes('suspen')) return 'Suspenso';
if (v.includes('ativ') || v.includes('andament')) return 'Em andamento';
return 'Em andamento';
}
async function exportAll() {
const data = {};
for (const s of STORES) data[s] = await dbGetAll(s);
data._version = 1;
data._exportedAt = new Date().toISOString();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const dt = new Date().toISOString().slice(0,10);
a.href = url;
a.download = `iuria_backup_${dt}.json`;
a.click();
URL.revokeObjectURL(url);
toast('Backup gerado', 'success');
}
async function importAll(e) {
const file = e.target.files[0];
if (!file) return;
// Na versão Supabase, restaurar backup direto pelo navegador é arriscado:
// pode duplicar registros (UUIDs novos) e bypass nenhum RLS. Backup/restore
// agora deve passar pelo script migrate.js ou export do próprio Supabase.
toast('Restauração via app não suportada na versão Supabase. Use o painel do Supabase ou rode migrate.js.', 'error');
e.target.value = '';
}
/* ==============================
UTIL
============================== */
function escape(s) {
if (s == null) return '';
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]);
}
function fmtDate(iso) {
if (!iso) return '-';
const parts = iso.split('-');
if (parts.length !== 3) return iso;
return `${parts[2]}/${parts[1]}/${parts[0]}`;
}
/* --------- Auth (Supabase) --------- */
// Abre/fecha o modal de login (dentro da landing page)
function abrirModalLogin() {
const m = document.getElementById('loginModal');
if (!m) return;
m.classList.add('open');
// foco no email pra começar a digitar direto
setTimeout(() => { document.getElementById('loginEmail')?.focus(); }, 100);
}
function fecharModalLogin() {
document.getElementById('loginModal')?.classList.remove('open');
}
// Esc fecha o modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') fecharModalLogin();
});
// Expõe globalmente pros onclick inline do HTML
window.abrirModalLogin = abrirModalLogin;
window.fecharModalLogin = fecharModalLogin;
// Disparado pelos botões dos cards de plano. Por enquanto leva pro WhatsApp
// (mais simples e funcional pro MVP). Quando tiver gateway de pagamento,
// trocar pra abrir checkout direto.
function contratarPlano(nomePlano) {
const mensagem = encodeURIComponent(
`Olá! Tenho interesse no plano ${nomePlano} do IURIA. Como faço pra começar o teste grátis?`
);
window.open(`https://wa.me/5581985069997?text=${mensagem}`, '_blank', 'noopener');
}
window.contratarPlano = contratarPlano;
function showLogin() {
document.getElementById('loginScreen').classList.add('open');
document.querySelector('.app').style.display = 'none';
}
// ===== Detecção do Agente Desktop instalado (heartbeat) → boas-vindas =====
let _agenteCheckIntv = null;
async function verificarAgenteInstalado() {
try {
const { data: { user } } = await sb.auth.getUser();
if (!user) return;
const flag = 'iuria_agente_ok_' + user.id;
if (localStorage.getItem(flag)) { if (_agenteCheckIntv) { clearInterval(_agenteCheckIntv); _agenteCheckIntv = null; } return; }
const { data: perfil } = await sb.from('perfis_usuario').select('agente_visto_em, nome').eq('user_id', user.id).maybeSingle();
if (!perfil?.agente_visto_em) return; // agente ainda não rodou
localStorage.setItem(flag, '1');
if (_agenteCheckIntv) { clearInterval(_agenteCheckIntv); _agenteCheckIntv = null; }
const primeiro = (perfil.nome || '').trim().split(' ')[0] || '';
const body = `
A instalação do Agente IURIA foi concluída e ele já está rodando no seu computador. Agora é só usar o IURIA normalmente — quando você aprovar uma petição, o agente protocola pra você (parando sempre antes da assinatura A3).
Seja bem-vindo à revolução do Direito. ⚖️
`;
openModal('🎉 Tudo pronto!', body, '');
} catch (e) { /* silencioso */ }
}
function iniciarCheckAgente() {
verificarAgenteInstalado();
if (_agenteCheckIntv) clearInterval(_agenteCheckIntv);
_agenteCheckIntv = setInterval(verificarAgenteInstalado, 25000);
}
window.verificarAgenteInstalado = verificarAgenteInstalado;
function hideLogin() {
document.getElementById('loginScreen').classList.remove('open');
document.querySelector('.app').style.display = '';
// Fecha o modal de login se estiver aberto (após login bem-sucedido)
fecharModalLogin();
// Gate de cobrança: mostra banner se o escritório estiver com acesso expirado
verificarBillingEscritorio();
// Detecta quando o Agente Desktop foi instalado e rodou → mensagem de boas-vindas.
iniciarCheckAgente();
// Veio do link '#credito' que o assistente manda quando o saldo está baixo?
_abrirRecargaPeloHash();
}
function updateUserUI(user) {
if (!user) return;
// Marca se o usuário logado é o DONO da plataforma (George) — habilita a
// visão de custo real/lucro nas análises de IA. Único ponto de verdade.
window.__iuriaOwner = calcularOwner(user);
const email = user.email || '';
const nameSpan = document.getElementById('userName');
const avatar = document.getElementById('userAvatar');
// Display name: prioridade (1) localStorage iuria_display_name; (2)
// user_metadata.full_name do Supabase; (3) fallback heurístico do email.
// O George pode customizar a qualquer momento via console:
// localStorage.setItem('iuria_display_name','Dr. Fulano')
let displayName = localStorage.getItem('iuria_display_name') || '';
if (!displayName) {
displayName = (user.user_metadata && (user.user_metadata.full_name || user.user_metadata.name)) || '';
}
if (!displayName) {
// Deriva do PRÓPRIO e-mail do usuário (parte antes do @). Não existe mapa de
// "e-mails conhecidos": nome de pessoa nunca é inferido por lista fixa —
// o refinamento abaixo troca isso pelo nome real de perfis_usuario.
const base = (email.split('@')[0] || '').replace(/[0-9_.-]+/g, ' ').trim();
displayName = base ? base.replace(/\b\w/g, c => c.toUpperCase()) : '—';
}
nameSpan.textContent = displayName;
// Avatar: inicial do primeiro nome real (ignora "Dr." e similares)
const semTitulo = displayName.replace(/^(Dr\.?|Dra\.?|Sr\.?|Sra\.?)\s+/i, '');
avatar.textContent = (semTitulo[0] || email[0] || '?').toUpperCase();
// Refina para "Dr./Dra. Primeiro" com o nome REAL do perfil (pedido do George 26/07:
// sempre doutor/doutora antes do nome no topo). Um nome custom salvo pelo usuário
// (iuria_display_name) sempre prevalece — não sobrescrevo.
(async () => {
try {
if (localStorage.getItem('iuria_display_name')) return;
await carregarAdvogados().catch(() => {});
const { data: perf } = await sb.from('perfis_usuario').select('nome').eq('user_id', user.id).maybeSingle();
const nomeReal = (perf && perf.nome) ? String(perf.nome).trim() : '';
if (!nomeReal) return;
const primeiro = nomeReal.split(/\s+/)[0];
nameSpan.textContent = `${_tratamentoDr(nomeReal)} ${primeiro}`;
avatar.textContent = (primeiro[0] || '?').toUpperCase();
} catch (e) {}
})();
}
async function doLogin() {
const email = document.getElementById('loginEmail').value.trim();
const password = document.getElementById('loginPassword').value;
const err = document.getElementById('loginError');
const btn = document.getElementById('loginBtn');
err.textContent = '';
if (!email || !password) { err.textContent = 'Preencha email e senha.'; return; }
btn.disabled = true; btn.textContent = 'Entrando...';
const { data, error } = await sb.auth.signInWithPassword({ email, password });
btn.disabled = false; btn.textContent = 'Entrar';
if (error) { err.textContent = error.message || 'Falha no login.'; return; }
// Check if user has MFA factors
const hasMfa = data.user?.factors?.length > 0;
if (hasMfa) {
// Show MFA code input, hide password form
document.getElementById('loginForm').style.display = 'none';
document.getElementById('mfaLoginStep').style.display = '';
document.getElementById('forgotBtn').style.display = 'none';
document.getElementById('loginError').textContent = '';
const codeInput = document.getElementById('mfaLoginCode');
codeInput.value = '';
setTimeout(() => codeInput.focus(), 100);
return; // wait for MFA code
}
// No MFA — normal login
updateUserUI(data.user);
hideLogin();
setTimeout(() => { document.getElementById('loginPassword').value = ''; }, 500);
renderView('dashboard');
}
let __mfaChallengeData = null;
async function mfaLoginVerify() {
const code = document.getElementById('mfaLoginCode').value.trim();
const err = document.getElementById('loginError');
if (!code || code.length < 6) { err.textContent = 'Digite o código de 6 dígitos.'; return; }
const btn = document.getElementById('mfaLoginBtn');
btn.disabled = true; btn.textContent = 'Verificando...';
err.textContent = '';
try {
const { data: { user } } = await sb.auth.getUser();
if (!user?.factors?.length) { err.textContent = 'Nenhum fator MFA encontrado.'; btn.disabled = false; btn.textContent = 'Verificar código'; return; }
const factor = user.factors.find(f => f.factor_type === 'totp' && f.status === 'verified');
if (!factor) { err.textContent = 'Fator MFA não encontrado.'; btn.disabled = false; btn.textContent = 'Verificar código'; return; }
const { data: challenge, error: cErr } = await sb.auth.mfa.challenge({ factorId: factor.id });
if (cErr) { err.textContent = cErr.message; btn.disabled = false; btn.textContent = 'Verificar código'; return; }
const { data: verifyData, error: vErr } = await sb.auth.mfa.verify({ factorId: factor.id, challengeId: challenge.id, code });
if (vErr) { err.textContent = vErr.message || 'Código inválido. Tente novamente.'; btn.disabled = false; btn.textContent = 'Verificar código'; return; }
btn.disabled = false; btn.textContent = 'Verificar código';
updateUserUI(verifyData.user || user);
hideLogin();
renderView('dashboard');
} catch (e) {
err.textContent = 'Erro: ' + (e.message || e);
btn.disabled = false; btn.textContent = 'Verificar código';
}
}
function mfaLoginCancel() {
document.getElementById('loginForm').style.display = 'contents';
document.getElementById('mfaLoginStep').style.display = 'none';
document.getElementById('forgotBtn').style.display = '';
document.getElementById('loginError').textContent = '';
}
// Auto-submit MFA code when 6 digits typed
document.addEventListener('input', function(e) {
if (e.target.id === 'mfaLoginCode' && e.target.value.replace(/\D/g,'').length === 6) {
e.target.value = e.target.value.replace(/\D/g,'');
mfaLoginVerify();
}
if (e.target.id === 'mfaEnrollCode' && e.target.value.replace(/\D/g,'').length === 6) {
e.target.value = e.target.value.replace(/\D/g,'');
mfaConfirm();
}
});
async function doForgot() {
const email = document.getElementById('loginEmail').value.trim();
const err = document.getElementById('loginError');
const btn = document.getElementById('forgotBtn');
if (!email) { err.style.color = 'var(--danger)'; err.textContent = 'Digite seu e-mail no campo acima e clique de novo em "Esqueci minha senha".'; return; }
if (btn) { btn.disabled = true; btn.textContent = 'Enviando…'; }
// 06/08: passa pela edge `recuperar-senha` em vez de falar direto com o Auth.
// Motivo: o Auth responde igual para e-mail cadastrado e não cadastrado, e o
// Dr. Jefferson ficou esperando um link que nunca saiu (tinha digitado outro
// e-mail). A edge DIZ quando o cadastro não existe — com limite por IP e
// registro, porque revelar isso é o que permite varrer e-mails.
let r = null;
try {
const resp = await fetch(SUPABASE_URL + '/functions/v1/recuperar-senha', {
method: 'POST',
headers: { 'content-type': 'application/json', apikey: SUPABASE_KEY },
body: JSON.stringify({ email, redirect_to: window.location.origin + window.location.pathname }),
});
r = await resp.json();
} catch (_e) { r = null; }
const ok = r && (r.resultado === 'enviado');
err.style.color = ok ? 'var(--success)' : 'var(--danger)';
err.textContent = r && r.mensagem
? r.mensagem
: 'Não consegui falar com o servidor agora. Tente de novo em instantes.';
// Cadastro inexistente: solta o botão na hora — ele vai corrigir o e-mail e
// tentar de novo. Segurar 60s aqui só irritaria.
const espera = ok ? 60000 : 1500;
setTimeout(() => { if (btn) { btn.disabled = false; btn.textContent = 'Esqueci minha senha'; } err.style.color = ''; }, espera);
}
/* ===== REDEFINIR SENHA (28/07): a parte que FALTAVA do "esqueci minha senha".
O link do e-mail traz o usuário de volta com uma sessão de recuperação; aqui
detectamos isso (evento PASSWORD_RECOVERY ou type=recovery no endereço) e
abrimos a tela para criar a senha nova. ===== */
function abrirNovaSenha() {
if (document.getElementById('novaSenhaOverlay')) return;
const inp = 'width:100%;padding:11px 12px;border:1px solid var(--border,#e2e8f0);border-radius:9px;background:var(--bg,#fff);color:inherit;font-size:15px;margin:4px 0 10px;box-sizing:border-box';
const ov = document.createElement('div');
ov.id = 'novaSenhaOverlay';
ov.style.cssText = 'position:fixed;inset:0;z-index:100001;background:rgba(2,8,23,.6);display:flex;align-items:center;justify-content:center;padding:16px';
ov.innerHTML = `
Criar nova senha
Você chegou pelo link de recuperação. Defina a sua nova senha de acesso ao IURIA.
`;
document.body.appendChild(ov);
document.getElementById('novaSenhaForm').addEventListener('submit', async (e) => {
e.preventDefault();
const s1 = document.getElementById('nsSenha1').value, s2 = document.getElementById('nsSenha2').value;
const msg = document.getElementById('nsMsg'), btn = document.getElementById('nsSalvar');
if (s1.length < 8) { msg.textContent = 'Use pelo menos 8 caracteres.'; return; }
if (s1 !== s2) { msg.textContent = 'As duas senhas não conferem.'; return; }
btn.disabled = true; btn.textContent = 'Salvando…';
const { error } = await sb.auth.updateUser({ password: s1 });
if (error) { btn.disabled = false; btn.textContent = 'Salvar nova senha e entrar'; msg.textContent = 'Erro: ' + error.message; return; }
try { history.replaceState(null, '', window.location.pathname); } catch (e2) {}
ov.remove();
try { toast('Senha alterada com sucesso! Você já está logado.', 'success'); } catch (e2) {}
setTimeout(() => location.reload(), 900);
});
setTimeout(() => { try { document.getElementById('nsSenha1').focus(); } catch (e2) {} }, 60);
}
// Detecta a volta pelo link de recuperação (dois caminhos, por robustez).
try {
sb.auth.onAuthStateChange((event) => { if (event === 'PASSWORD_RECOVERY') abrirNovaSenha(); });
} catch (e) {}
if (/type=recovery/.test(window.location.hash || '') || /type=recovery/.test(window.location.search || '')) {
window.addEventListener('load', () => setTimeout(abrirNovaSenha, 400));
}
window.abrirNovaSenha = abrirNovaSenha;
async function doLogout() {
await sb.auth.signOut();
location.reload();
}
document.getElementById('loginForm').addEventListener('submit', (e) => {
e.preventDefault();
doLogin();
});
document.getElementById('forgotBtn').onclick = doForgot;
document.getElementById('logoutBtn').onclick = doLogout;
/* --------- MFA (verificação em duas etapas) --------- */
// Render MFA status in Configurações card
async function mfaRender() {
const wrap = document.getElementById('mfaStatusWrap');
if (!wrap) return;
try {
const { data: { user } } = await sb.auth.getUser();
if (!user) { wrap.innerHTML = '
Faça login para gerenciar a verificação em duas etapas.
`;
btn.disabled = false; btn.textContent = 'Ativar verificação em duas etapas';
}
}
async function mfaConfirm() {
const code = document.getElementById('mfaEnrollCode')?.value.trim();
const err = document.getElementById('mfaEnrollError');
if (!err) return;
if (!code || code.length < 6) { err.textContent = 'Digite o código de 6 dígitos do seu app autenticador.'; return; }
const ed = window.__mfaEnrollData;
if (!ed) { err.textContent = 'Sessão de ativação expirada. Clique em "Ativar" novamente.'; return; }
err.textContent = '';
try {
const { data, error } = await sb.auth.mfa.verify({ factorId: ed.factorId, challengeId: ed.challengeId, code });
if (error) { err.textContent = error.message || 'Código inválido. Tente novamente.'; return; }
window.__mfaEnrollData = null;
toast('✅ Verificação em duas etapas ativada com sucesso!', 'success');
mfaRender();
} catch (e) {
err.textContent = 'Erro: ' + (e.message || e);
}
}
function mfaCancelEnroll() {
window.__mfaEnrollData = null;
const area = document.getElementById('mfaEnrollArea');
if (area) { area.style.display = 'none'; area.innerHTML = ''; }
}
async function mfaDisable() {
if (!confirm('Tem certeza que deseja desativar a verificação em duas etapas? Isso reduz a segurança da sua conta.')) return;
try {
const { data: { user } } = await sb.auth.getUser();
if (!user?.factors?.length) return;
const factor = user.factors.find(f => f.factor_type === 'totp' && f.status === 'verified');
if (!factor) return;
const { error } = await sb.auth.mfa.unenroll({ factorId: factor.id });
if (error) { toast('Erro ao desativar: ' + error.message, 'error'); return; }
toast('✅ Verificação em duas etapas desativada.', 'success');
mfaRender();
} catch (e) {
toast('Erro: ' + (e.message || e), 'error');
}
}
window.mfaRender = mfaRender;
window.mfaEnroll = mfaEnroll;
window.mfaConfirm = mfaConfirm;
window.mfaCancelEnroll = mfaCancelEnroll;
window.mfaDisable = mfaDisable;
window.mfaLoginVerify = mfaLoginVerify;
window.mfaLoginCancel = mfaLoginCancel;
// ===== Alternância de tema claro/escuro =====
// O tema inicial já é aplicado no (antes da pintura) lendo localStorage.
// Aqui só tratamos o clique: inverte o atributo data-theme e persiste a escolha.
/* ===== TEMAS · Configurações › Aparência (design 22/07) =====
6 paletas completas. Cada tema "re-tinge" o app inteiro pelas variáveis CSS que
JÁ existem — não reescreve estilo nenhum. O campo `modo` diz se o tema usa a base
clara ou escura (data-theme), reaproveitando as regras de modo escuro do CSS.
O botão sol/lua alterna entre o último tema ESCURO e o último CLARO escolhidos. */
const IURIA_TEMAS = [
{ id:'neural', nome:'Neural', desc:'o clássico', modo:'escuro',
v:{ bg:'#06080B', panel:'#0D1218', panel2:'#111922', line:'#1C2833', text:'#E9EEF3', muted:'#8695A3', accent:'#00E5FF', danger:'#FF6B6B', warn:'#F5B84E', ok:'#3ED598' } },
{ id:'indigo', nome:'Índigo', desc:'moderno e sóbrio', modo:'escuro',
v:{ bg:'#0A0E1F', panel:'#141A32', panel2:'#1B2240', line:'#28315A', text:'#E7E9F5', muted:'#8A90B5', accent:'#8B7CFF', danger:'#FF7A85', warn:'#F6C15C', ok:'#57D6A6' } },
{ id:'esmeralda', nome:'Esmeralda', desc:'calmo, menos cansaço', modo:'escuro',
v:{ bg:'#07110D', panel:'#0F1C17', panel2:'#14251E', line:'#1E322A', text:'#E6F0EB', muted:'#87A197', accent:'#34D399', danger:'#FF7A7A', warn:'#EBC15E', ok:'#34D399' } },
{ id:'grafite', nome:'Grafite', desc:'neutro e institucional', modo:'escuro',
v:{ bg:'#161719', panel:'#1F2124', panel2:'#26292D', line:'#33373C', text:'#E8EAED', muted:'#9AA1A9', accent:'#7FB2E8', danger:'#EF7A7A', warn:'#E3B15C', ok:'#63C89A' } },
{ id:'aurora', nome:'Aurora', desc:'claro e limpo', modo:'claro',
v:{ bg:'#F1F4F8', panel:'#FFFFFF', panel2:'#F7F9FC', line:'#E2E8F0', text:'#16202E', muted:'#64748B', accent:'#0AA6C2', danger:'#E5484D', warn:'#C77A05', ok:'#0E9F6E' } },
{ id:'papel', nome:'Papel', desc:'claro, tom sépia', modo:'claro',
v:{ bg:'#F3EEE3', panel:'#FDFBF5', panel2:'#F7F2E8', line:'#E4DBCA', text:'#2C2A24', muted:'#7A7365', accent:'#1F6F5C', danger:'#B23A3A', warn:'#A9750C', ok:'#1F6F5C' } },
];
function _temaPorId(id) { return IURIA_TEMAS.find(t => t.id === id) || IURIA_TEMAS[0]; }
function _temaAtual() {
try {
const v = localStorage.getItem('iuria_tema');
if (v && IURIA_TEMAS.some(t => t.id === v)) return v;
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'neural' : 'aurora';
} catch (e) { return 'neural'; }
}
function _temaAplicar(id, persistir) {
const t = _temaPorId(id), r = document.documentElement;
r.setAttribute('data-theme', t.modo === 'claro' ? 'light' : 'dark');
const map = {
'--bg': t.v.bg, '--surface': t.v.panel, '--surface-alt': t.v.panel2, '--border': t.v.line,
'--text': t.v.text, '--text-muted': t.v.muted,
'--primary': t.v.accent, '--primary-dark': t.v.accent,
'--danger': t.v.danger, '--warning': t.v.warn, '--success': t.v.ok,
};
for (const [k, val] of Object.entries(map)) r.style.setProperty(k, val);
// fundo suave do acento (chips/realces), derivado do próprio acento do tema
r.style.setProperty('--primary-light', `color-mix(in srgb, ${t.v.accent} 14%, transparent)`);
if (persistir !== false) {
try {
localStorage.setItem('iuria_tema', t.id);
localStorage.setItem('iuria_theme', t.modo === 'claro' ? 'light' : 'dark');
localStorage.setItem(t.modo === 'claro' ? 'iuria_tema_light' : 'iuria_tema_dark', t.id);
} catch (e) {}
}
}
// Sol/lua: vai para o último tema escolhido do OUTRO modo (padrão Neural ↔ Aurora).
function toggleTheme() {
const escuroAgora = document.documentElement.getAttribute('data-theme') === 'dark';
let alvo = null;
try { alvo = localStorage.getItem(escuroAgora ? 'iuria_tema_light' : 'iuria_tema_dark'); } catch (e) {}
if (!alvo || !IURIA_TEMAS.some(t => t.id === alvo)) alvo = escuroAgora ? 'aurora' : 'neural';
_temaAplicar(alvo);
}
// Card do seletor (Configurações › Aparência), com amostra das cores de cada tema.
function _temaCardBody() {
const atual = _temaAtual();
const opts = IURIA_TEMAS.map(t => `
`).join('');
return `
A escolha vale para todo o sistema e fica salva neste aparelho. O botão de sol/lua no topo alterna entre o seu tema escuro e o claro preferidos.
${opts}
`;
}
function _temaEscolher(id) {
_temaAplicar(id);
document.querySelectorAll('.tema-opt').forEach(b => {
const on = b.dataset.tema === id;
b.classList.toggle('sel', on); b.setAttribute('aria-pressed', on ? 'true' : 'false');
});
try { toast(`Tema "${_temaPorId(id).nome}" aplicado`, 'success'); } catch (e) {}
}
// Aplica o tema salvo assim que o script carrega (o já definiu claro/escuro).
try { _temaAplicar(_temaAtual(), false); } catch (e) {}
document.getElementById('themeToggle')?.addEventListener('click', toggleTheme);
// ===== Cadastro self-service (teste grátis: cria escritório + admin) =====
function abrirModalCadastro() {
const m = document.getElementById('cadastroModal');
if (!m) return;
const err = document.getElementById('cadastroError'); if (err) err.textContent = '';
fecharModalLogin();
m.classList.add('open');
setTimeout(() => document.getElementById('cadEscritorio')?.focus(), 100);
}
function fecharModalCadastro() {
document.getElementById('cadastroModal')?.classList.remove('open');
}
window.abrirModalCadastro = abrirModalCadastro;
window.fecharModalCadastro = fecharModalCadastro;
async function criarEscritorioTrial() {
const err = document.getElementById('cadastroError');
const btn = document.getElementById('cadBtn');
err.textContent = '';
const escritorio_nome = document.getElementById('cadEscritorio').value.trim();
const admin_nome = document.getElementById('cadNome').value.trim();
const admin_email = document.getElementById('cadEmail').value.trim();
const admin_senha = document.getElementById('cadSenha').value;
const oab = document.getElementById('cadOab').value.trim();
const oab_uf = document.getElementById('cadOabUf').value.trim();
const whatsapp = _telBRNormalizar(document.getElementById('cadWhats').value);
const aceite_contato = !!document.getElementById('cadAceite')?.checked;
const consentimento = !!document.getElementById('cadConsentimento')?.checked;
const website = document.getElementById('cadWebsite').value; // honeypot
if (!escritorio_nome || !admin_nome || !admin_email || !admin_senha) { err.textContent = 'Preencha o escritório, nome, e-mail e senha.'; return; }
if (admin_senha.length < 6) { err.textContent = 'A senha precisa de pelo menos 6 caracteres.'; return; }
if (!oab || !oab_uf) { err.textContent = 'Informe o número e a UF da sua OAB.'; return; }
if (!whatsapp) { err.textContent = 'Informe seu WhatsApp (com DDD).'; document.getElementById('cadWhats')?.focus(); return; }
if (whatsapp.length < 10 || whatsapp.length > 11) { err.textContent = 'WhatsApp inválido — informe DDD + número, ex.: (81) 99999-8888.'; document.getElementById('cadWhats')?.focus(); return; }
if (!consentimento) { err.textContent = 'Para criar a conta, é preciso aceitar a Política de Privacidade e os Termos de Uso.'; return; }
btn.disabled = true; btn.textContent = 'Criando sua conta...';
try {
const { data, error } = await sb.functions.invoke('criar-escritorio', {
body: { escritorio_nome, admin_nome, admin_email, admin_senha, oab, oab_uf, whatsapp, aceite_contato, website,
aceite_termos: true, aceite_termos_versao: (typeof LGPD_DOC_VERSAO !== 'undefined' ? LGPD_DOC_VERSAO : null) },
});
let resp = data;
if (error) { try { resp = await error.context.json(); } catch (_) { resp = null; } }
if (!resp || !resp.ok) {
const msg = (resp && resp.error) ? resp.error : 'Não foi possível criar a conta. Tente novamente.';
// "Já existe conta com esse e-mail" → oferece login + recuperar senha na hora.
if (/j[áa] existe|j[áa] cadastrad/i.test(msg)) {
err.innerHTML = escape(msg) + ' Entrar / recuperar senha';
} else {
err.textContent = msg;
}
btn.disabled = false; btn.textContent = 'Criar conta grátis';
return;
}
// Sucesso: loga automaticamente e entra
const { data: signin, error: sErr } = await sb.auth.signInWithPassword({ email: admin_email, password: admin_senha });
if (sErr) {
fecharModalCadastro(); abrirModalLogin();
const le = document.getElementById('loginError'); if (le) le.textContent = 'Conta criada! Faça login com seu e-mail e senha.';
return;
}
fecharModalCadastro();
updateUserUI(signin.user);
hideLogin();
renderView('dashboard');
toast('Bem-vindo ao IURIA! Seu escritório foi criado.', 'success');
// Pós-cadastro: se o cartão estiver habilitado, mostra o passo 2 (plano + cartão);
// senão, abre o download e já tenta baixar o instalador automaticamente.
setTimeout(() => { try { if (typeof CARTAO_HABILITADO !== 'undefined' && CARTAO_HABILITADO) abrirFinalizarCartao(); else abrirDownloadAgente(true); } catch (e) {} }, 1000);
} catch (e) {
err.textContent = 'Erro: ' + (e.message || e);
btn.disabled = false; btn.textContent = 'Criar conta grátis';
}
}
window.criarEscritorioTrial = criarEscritorioTrial;
// ===== Download do Agente de peticionamento (instalador + versão portátil) =====
function abrirDownloadAgente(auto) {
const EXE = 'https://drive.google.com/uc?export=download&id=10TZhrdMWQYvbQamB3GAdYC-XHq4L6C-I';
const ZIP = 'https://drive.google.com/uc?export=download&id=1jHrGVYK2qgBnSxdU_zoKo6OHvOLJIAIl';
const body = `
O Agente IURIA roda no seu computador e faz o peticionamento eletrônico pra você — com o seu login no PJe e o seu certificado A3. Instale uma vez e pronto.
Dois cliques no arquivo baixado. Se aparecer a tela azul do Windows, clique em "Mais informações → Executar assim mesmo" (é normal e seguro — o app ainda não é assinado).
O antivírus ou o navegador bloqueou o download do .exe?
';
} else {
html += pvdAviso('Nenhum vínculo retornado pela leitura. Confira o texto/PDF do CNIS e tente novamente.', 'info');
}
html += '
' + ic('info') + ' Extrato salvo e vinculado ao cliente' + (pvdState.processoId ? ' e ao processo selecionado' : '') + '. Agora use as abas Cálculos e Cruzamento.
';
html += '
';
out.innerHTML = html;
}
// --------- ABA 2: cálculos ---------
async function pvdCalcular(tipo) {
if (!pvdState.extratoId) { toast('Importe um extrato CNIS antes de calcular.', 'warning'); pvdTrocarAba('cnis'); return; }
const drEl = document.getElementById('pvdDataRef');
const dataRef = (drEl && drEl.value) || pvdState.dataRef || new Date().toISOString().slice(0, 10);
const body = { extrato_id: pvdState.extratoId, tipo: tipo, data_referencia: dataRef };
const opcoes = {};
if (tipo === 'qualidade_segurado') {
const d = document.getElementById('pvdQualDesemprego'); const m = document.getElementById('pvdQualMais120');
opcoes.desemprego_comprovado = !!(d && d.checked);
opcoes.mais_120_contrib = !!(m && m.checked);
}
if (tipo === 'carencia') {
const b = document.getElementById('pvdCarBeneficio');
if (b && b.value) { body.beneficio = b.value; } else { toast('Escolha o benefício para calcular a carência.', 'warning'); return; }
}
if (Object.keys(opcoes).length) body.opcoes = opcoes;
const out = document.getElementById('pvdCalcOut_' + tipo); if (!out) return;
out.innerHTML = '
';
}
function pvdListaBloco(titulo, arr, tipo) {
const itens = (arr || []).map(function (x) { return pvdEsc(pvdTextoDe(x)); });
return pvdSecaoLista('' + pvdEsc(titulo) + '', itens, false);
}
function pvdParamsUsados(obj) {
const keys = Object.keys(obj || {});
if (!keys.length) return '';
const val = function (v) { return (v != null && typeof v === 'object') ? JSON.stringify(v) : String(v); };
return '' + ic('gear') + ' Parâmetros usados
' +
keys.map(function (k) { return '
' + pvdEsc(k) + '
' + pvdEsc(val(obj[k])) + '
'; }).join('') + '
';
}
function pvdRenderCalc(tipo, data, out) {
const r = (data && data.resultado) || {};
let html = PVD_SELO;
if (tipo === 'qualidade_segurado') {
const ok = r.mantem_qualidade;
html += pvdBadgeResultado(ok === true ? true : (ok === false ? false : null), ok === true ? 'Aparenta MANTER a qualidade de segurado (a conferir)' : (ok === false ? 'Pode ter PERDIDO a qualidade de segurado (a conferir)' : 'Qualidade de segurado apurada (a conferir)'));
html += pvdKV([
['Meses de graça aplicados', r.meses_graca_aplicados],
['Fim do período de graça', r.data_fim_graca ? pvdFmtData(r.data_fim_graca) : null],
['Perda da qualidade', r.data_perda_qualidade ? pvdFmtData(r.data_perda_qualidade) : null],
]);
} else if (tipo === 'carencia') {
const ok = r.carencia_cumprida;
html += pvdBadgeResultado(ok === true ? true : (ok === false ? false : null), ok === true ? 'Carência aparentemente CUMPRIDA (a conferir)' : (ok === false ? 'Carência aparentemente NÃO cumprida (a conferir)' : 'Carência apurada (a conferir)'));
html += pvdKV([
['Contribuições válidas', r.qtd_contribuicoes_validas],
['Exigidas', r.qtd_exigida],
['Faltam', r.faltam],
['Dispensada', r.dispensada === true ? 'Sim' : (r.dispensada === false ? 'Não' : null)],
]);
} else if (tipo === 'tempo_contribuicao') {
const tt = r.tempo_total || {};
const temTempo = (tt.anos != null || tt.meses != null || tt.dias != null);
html += pvdBadgeResultado(null, 'Tempo de contribuição apurado (a conferir)');
html += pvdKV([
['Tempo total', temTempo ? ((tt.anos || 0) + 'a ' + (tt.meses || 0) + 'm ' + (tt.dias || 0) + 'd') : null],
]);
if (Array.isArray(r.lacunas) && r.lacunas.length) html += pvdListaBloco('Lacunas detectadas', r.lacunas, 'warning');
if (Array.isArray(r.periodos_a_investigar) && r.periodos_a_investigar.length) html += pvdListaBloco('Períodos a investigar', r.periodos_a_investigar, 'info');
}
const mem = r.memoria_calculo || (data && data.memoria_calculo);
if (Array.isArray(mem) && mem.length) html += pvdSecaoLista(ic('calculator') + ' Memória de cálculo', mem.map(pvdLinhaMemoria), true);
if (Array.isArray(data.base_legal) && data.base_legal.length) html += pvdSecaoLista(ic('scale') + ' Base legal', data.base_legal.map(pvdLinhaBase), false);
if (Array.isArray(data.assuncoes) && data.assuncoes.length) html += pvdSecaoLista(ic('info') + ' Assunções', data.assuncoes.map(function (a) { return pvdEsc(pvdTextoDe(a)); }), false);
if (data.parametros_usados && Object.keys(data.parametros_usados).length) html += pvdParamsUsados(data.parametros_usados);
out.innerHTML = html;
}
// --------- ABA 3: cruzamento com o indeferimento ---------
function pvdChipForca(f) {
const s = String(f == null ? '' : f).toLowerCase();
let cls = 'info', txt = (f == null || f === '') ? '—' : f;
if (/forte|alta|high|3/.test(s)) { cls = 'success'; txt = 'Força alta'; }
else if (/m[eé]dia|media|medium|2/.test(s)) { cls = 'warning'; txt = 'Força média'; }
else if (/fraca|baixa|low|1/.test(s)) { cls = 'danger'; txt = 'Força baixa'; }
return '' + pvdEsc(txt) + '';
}
async function pvdCruzamento() {
if (!pvdState.extratoId) { toast('Importe um extrato CNIS antes de cruzar.', 'warning'); pvdTrocarAba('cnis'); return; }
const tEl = document.getElementById('pvdCruzTexto');
const texto = (tEl && tEl.value || '').trim();
if (!texto && !pvdState.processoId) { toast('Cole o texto do indeferimento ou selecione um processo.', 'warning'); return; }
const drEl = document.getElementById('pvdDataRef');
const dataRef = (drEl && drEl.value) || pvdState.dataRef || new Date().toISOString().slice(0, 10);
const body = { extrato_id: pvdState.extratoId, data_referencia: dataRef };
if (texto) body.texto_indeferimento = texto; else body.processo_id = pvdState.processoId;
const btn = document.getElementById('pvdCruzBtn'); const out = document.getElementById('pvdCruzResultado');
if (btn) { btn.disabled = true; btn.dataset._t = btn.textContent; btn.textContent = 'Cruzando…'; }
if (out) out.innerHTML = '
Comparando com o CNIS…
';
try {
const data = await pvdInvoke('prev-cruzamento', body);
pvdRenderCruzamento(data, out);
} catch (e) { if (out) out.innerHTML = pvdAviso(pvdErroAmigavel(e), 'danger'); }
finally { if (btn) { btn.disabled = false; btn.textContent = btn.dataset._t || 'Cruzar com o CNIS'; } }
}
function pvdRenderCruzamento(data, out) {
const cons = Array.isArray(data.contradicoes) ? data.contradicoes : [];
let html = '
' + PVD_SELO + '
';
if (data.resumo) html += pvdAviso(typeof data.resumo === 'string' ? data.resumo : (data.resumo.texto || JSON.stringify(data.resumo)), 'info');
if (!cons.length) {
html += pvdAviso('Nenhuma contradição encontrada entre o indeferimento e o CNIS lido. Isso NÃO confirma o indeferimento — revise manualmente.', 'warning');
out.innerHTML = html; return;
}
html += '
Motivo do INSS
O que o CNIS mostra
Força
' +
cons.map(function (c) { return '
' + pvdEsc(c.motivo_inss || '—') + '
' + pvdEsc(c.o_que_o_cnis_mostra || '—') + '
' + pvdChipForca(c.forca) + '
'; }).join('') +
'
';
html += '
' + ic('info') + ' Cada contradição é uma HIPÓTESE a conferir na íntegra dos autos e no CNIS oficial antes de sustentar em juízo.
';
out.innerHTML = html;
}
// --------- ABA 4: minuta (reaproveita a SIA) ---------
function pvdColetaBase(arr, set) {
if (!Array.isArray(arr)) return;
for (const b of arr) {
const s = (typeof b === 'string') ? b : (b.dispositivo || b.artigo || b.norma || b.lei || b.texto || '');
if (s && set.indexOf(s) < 0) set.push(String(s));
}
}
function pvdMontarInsumo() {
const c = pvdState.calc || {};
if (!(c.qualidade_segurado || c.carencia || c.tempo_contribuicao)) return '';
const cli = (pvdState.clientes || []).find(function (x) { return x.id === pvdState.clienteId; });
const drEl = document.getElementById('pvdDataRef');
const dataRef = (drEl && drEl.value) || pvdState.dataRef || '';
const L = [];
L.push('DADOS PREVIDENCIÁRIOS CALCULADOS NO IURIA (premissas a CONFERIR — não afirmam direito):');
if (cli && cli.nome) L.push('Cliente: ' + cli.nome);
if (dataRef) L.push('Data de referência: ' + pvdFmtData(dataRef));
const baseSet = [];
const q = c.qualidade_segurado;
if (q && q.resultado) {
const r = q.resultado; L.push(''); L.push('QUALIDADE DE SEGURADO:');
L.push('- Mantém a qualidade: ' + (r.mantem_qualidade === true ? 'sim' : (r.mantem_qualidade === false ? 'não' : 'indeterminado')) + ' (a conferir)');
if (r.data_fim_graca) L.push('- Fim do período de graça: ' + pvdFmtData(r.data_fim_graca));
if (r.data_perda_qualidade) L.push('- Perda da qualidade: ' + pvdFmtData(r.data_perda_qualidade));
if (r.meses_graca_aplicados != null) L.push('- Meses de graça aplicados: ' + r.meses_graca_aplicados);
pvdColetaBase(q.base_legal, baseSet);
}
const car = c.carencia;
if (car && car.resultado) {
const r = car.resultado; L.push(''); L.push('CARÊNCIA:');
L.push('- Cumprida: ' + (r.carencia_cumprida === true ? 'sim' : (r.carencia_cumprida === false ? 'não' : 'indeterminado')) + ' (a conferir)');
if (r.qtd_contribuicoes_validas != null) L.push('- Contribuições válidas: ' + r.qtd_contribuicoes_validas);
if (r.qtd_exigida != null) L.push('- Exigidas: ' + r.qtd_exigida);
if (r.faltam != null) L.push('- Faltam: ' + r.faltam);
if (r.dispensada != null) L.push('- Dispensada: ' + (r.dispensada ? 'sim' : 'não'));
pvdColetaBase(car.base_legal, baseSet);
}
const t = c.tempo_contribuicao;
if (t && t.resultado) {
const r = t.resultado; const tt = r.tempo_total || {}; L.push(''); L.push('TEMPO DE CONTRIBUIÇÃO:');
L.push('- Tempo total (a conferir): ' + (tt.anos || 0) + 'a ' + (tt.meses || 0) + 'm ' + (tt.dias || 0) + 'd');
if (Array.isArray(r.lacunas) && r.lacunas.length) L.push('- Lacunas: ' + r.lacunas.map(pvdTextoDe).join(' | '));
if (Array.isArray(r.periodos_a_investigar) && r.periodos_a_investigar.length) L.push('- Investigar: ' + r.periodos_a_investigar.map(pvdTextoDe).join(' | '));
pvdColetaBase(t.base_legal, baseSet);
}
if (baseSet.length) { L.push(''); L.push('BASE LEGAL CITADA (conferir): ' + baseSet.slice(0, 12).join('; ')); }
L.push('');
L.push('Com base nesses números (a CONFERIR), elabore a minuta da petição previdenciária cabível. Não afirme direito adquirido sem revisão; trate os valores como premissas do advogado.');
return L.join('\n');
}
function pvdAtualizarMinuta() {
const ta = document.getElementById('pvdMinutaPreview'); if (!ta) return;
const txt = pvdMontarInsumo();
const vazio = document.getElementById('pvdMinutaVazio');
ta.value = txt || '';
if (vazio) vazio.style.display = txt ? 'none' : '';
}
function pvdCopiarInsumo() {
const ta = document.getElementById('pvdMinutaPreview');
if (!ta || !ta.value.trim()) { toast('Nada para copiar — faça ao menos um cálculo.', 'warning'); return; }
try { navigator.clipboard.writeText(ta.value); toast('Insumo copiado.', 'success'); }
catch (_) { ta.select(); try { document.execCommand('copy'); } catch (e) {} toast('Insumo copiado.', 'success'); }
}
async function pvdIrParaMinuta() {
const ta = document.getElementById('pvdMinutaPreview');
const texto = (ta && ta.value.trim()) ? ta.value : pvdMontarInsumo();
if (!texto) { toast('Faça ao menos um cálculo antes de gerar a minuta.', 'warning'); pvdTrocarAba('calculos'); return; }
try { await navigator.clipboard.writeText(texto); } catch (_) {}
const btn = document.querySelector('.nav-btn[data-view="sia"]');
if (btn) btn.click();
else if (typeof irParaView === 'function') irParaView('sia');
else renderView('sia');
let tentativas = 0;
const timer = setInterval(function () {
const inp = document.getElementById('siaInput');
if (inp || tentativas++ > 30) {
clearInterval(timer);
if (inp) {
try { siaTrocarAba('criar'); } catch (_) {}
inp.value = texto; inp.focus();
toast('Dados na SIA. Revise e clique em Gerar. (O insumo também foi copiado.)', 'success');
}
}
}, 100);
}
// --------- ABA 5: configurações do módulo (prev_parametros) ---------
async function pvdCarregarParametros() {
const box = document.getElementById('pvdConfigLista'); if (!box) return;
box.innerHTML = '
Carregando…
';
try {
const esc = await pvdEscritorioAtual();
const { data, error } = await sb.from('prev_parametros').select('*').order('id');
if (error) throw error;
// A RLS devolve o parametro GLOBAL (escritorio null) + o override do meu escritorio.
// Mostro UMA linha por chave, preferindo o override do escritorio (se existir).
const porChave = {};
for (const r of (data || [])) {
const k = (r.chave != null && r.chave !== '') ? r.chave : ('id:' + r.id);
if (!porChave[k] || (esc && r.escritorio === esc)) porChave[k] = r;
}
pvdRenderParametros(Object.keys(porChave).map(function (k) { return porChave[k]; }));
} catch (e) {
box.innerHTML = pvdAviso(pvdErroAmigavel(e && e.message ? e : { message: (e && e.message) || String(e) }), 'info');
}
}
function pvdRenderParametros(rows) {
const box = document.getElementById('pvdConfigLista'); if (!box) return;
if (!rows.length) { box.innerHTML = pvdAviso('Nenhum parâmetro cadastrado ainda. O backend cria os padrões (graça, carências, cortes/EC) na primeira execução — recarregue depois.', 'info'); return; }
const SYS = ['id', 'escritorio', 'escritorio_id', 'created_at', 'updated_at', 'atualizado_em', 'criado_por', 'validado_por', 'validado_em'];
const BASE = ['base_legal', 'fundamento'];
const LBL = ['rotulo', 'nome', 'titulo', 'parametro', 'chave', 'codigo', 'descricao', 'tipo'];
box.innerHTML = rows.map(function (row) {
const rid = row.id;
let titleField = null; for (const k of LBL) { if (row[k] != null && row[k] !== '') { titleField = k; break; } }
const titulo = titleField ? String(row[titleField]) : ('Parâmetro ' + rid);
const editar = Object.keys(row).filter(function (k) { return SYS.indexOf(k) < 0 && BASE.indexOf(k) < 0 && k !== titleField; });
const campos = editar.map(function (k) {
const v = row[k];
const isObj = v != null && typeof v === 'object';
const isNum = typeof v === 'number';
if (isObj) {
return '';
}
return '';
}).join('');
let baseHtml = '';
BASE.forEach(function (bk) {
if (bk in row) baseHtml += '';
});
let status = '';
if ('validado_em' in row) {
status = row.validado_em
? '' + ic('checkCircle', 12) + ' Validado em ' + pvdFmtData(row.validado_em) + ''
: '' + ic('alert', 12) + ' Não validado';
}
return '
' +
'
' + pvdEsc(titulo) + '
' + status + '
' +
campos + baseHtml +
'
' +
('validado_em' in row ? '' : '') +
'
';
}).join('');
}
// escritorio do usuario logado (para gravar override sem tocar no parametro GLOBAL)
async function pvdEscritorioAtual() {
try {
const u = (await sb.auth.getUser()).data.user;
if (!u) return null;
const { data: perfil } = await sb.from('perfis_usuario').select('escritorio_id').eq('user_id', u.id).maybeSingle();
return perfil ? perfil.escritorio_id : null;
} catch (_) { return null; }
}
// Grava valor/validacao SEM alterar o parametro GLOBAL (escritorio null): a RLS so deixa o
// app escrever linhas do proprio escritorio. Se a linha for global, cria/atualiza um OVERRIDE
// do escritorio (upsert por escritorio+chave). O backend le o override antes do global.
async function pvdGravarParametro(rid, patch) {
const esc = await pvdEscritorioAtual();
if (!esc) { toast('Seu perfil não tem escritório — não é possível gravar.', 'danger'); return false; }
const { data: row, error: e0 } = await sb.from('prev_parametros').select('*').eq('id', rid).maybeSingle();
if (e0 || !row) { toast('Parâmetro não encontrado.', 'danger'); return false; }
try {
if (row.escritorio === esc) {
const { error } = await sb.from('prev_parametros').update(patch).eq('id', rid);
if (error) throw error;
} else {
const novo = {
escritorio: esc,
chave: row.chave,
valor: ('valor' in patch) ? patch.valor : row.valor,
descricao: ('descricao' in patch) ? patch.descricao : row.descricao,
base_legal: row.base_legal
};
if ('validado_por' in patch) { novo.validado_por = patch.validado_por; novo.validado_em = patch.validado_em; }
const { error } = await sb.from('prev_parametros').upsert(novo, { onConflict: 'escritorio,chave' });
if (error) throw error;
}
return true;
} catch (e) { toast('Falha ao gravar: ' + pvdErroAmigavel(e && e.message ? e : { message: String(e) }), 'danger'); return false; }
}
async function pvdSalvarParametro(rid) {
const inputs = document.querySelectorAll('[data-pvd-param="' + rid + '"]');
const patch = {};
for (const el of inputs) {
const f = el.getAttribute('data-field');
let v = el.value;
if (el.getAttribute('data-json') === '1') {
try { v = v.trim() ? JSON.parse(v) : null; } catch (_) { toast('JSON inválido no campo "' + f + '".', 'danger'); return; }
} else if (el.getAttribute('data-type') === 'number') {
v = v === '' ? null : Number(v);
}
patch[f] = v;
}
if (!Object.keys(patch).length) { toast('Nada para salvar.', 'warning'); return; }
if (await pvdGravarParametro(rid, patch)) {
toast('Parâmetro salvo para o seu escritório. Não esqueça de validar.', 'success');
pvdCarregarParametros();
}
}
async function pvdValidarParametro(rid) {
const u = (await sb.auth.getUser()).data.user;
if (await pvdGravarParametro(rid, { validado_por: (u && u.id) || null, validado_em: new Date().toISOString() })) {
toast('Parâmetro validado para o seu escritório.', 'success');
pvdCarregarParametros();
}
}
// --------- VIEW ---------
views.previdenciario = async (main) => {
main.innerHTML = `
${ic('scale', 26)} Previdenciário
CNIS, cálculos de qualidade/carência/tempo e cruzamento com o indeferimento do INSS
Módulo Previdenciário · IURIA
${ic('alert', 18)}
Ferramenta de apoio. Os números são estimativas para conferência do advogado — não afirmam direito nem substituem a análise jurídica. Confira sempre a fonte (CNIS oficial, legislação e provas dos autos).
e / ou
${ic('lock')} O extrato fica restrito ao escritório selecionado (multi-tenant).
Data usada como referência nos três cálculos (ex.: Data de Entrada do Requerimento).
${ic('user')} Qualidade de segurado
${ic('clock')} Carência
${ic('scale')} Tempo de contribuição
Apura o tempo total a partir dos vínculos do CNIS e aponta lacunas.
${ic('info')} A minuta reaproveita o gerador de peças da SIA. Os números calculados vão como insumo — a redação e a revisão são do advogado.
${pvdAviso('Faça ao menos um cálculo na aba "Cálculos" para montar o insumo.', 'info')}
${pvdAviso('Estes parâmetros REGEM todos os cálculos (períodos de graça, carências exigidas, cortes/EC). O advogado é responsável por conferi-los e VALIDÁ-los. Um parâmetro não validado ainda assim é usado — a validação é o registro de que o advogado conferiu.', 'warning')}
Parâmetros do módulo
Carregando…
`;
await pvdInit();
};
/* ==== fim MODULO PREVIDENCIARIO ==== */
// ---- Lista de quadros ----
views.sia = async (main) => {
main.innerHTML = `
SIA
Sistema de Inteligência Artificial — Crie contratos, analise documentos e muito mais
SIA v1.0 · IURIA
Contrato de Aluguel
Locação residencial ou comercial
Contrato de Comodato
Empréstimo gratuito de bem
Contrato Comercial
Prestação de serviços entre empresas
Honorários
Serviços advocatícios com êxito
NDA
Acordo de confidencialidade
Olá! Sou a SIA, sua assistente jurídica.
Selecione um card acima ou digite o que precisa:
• Aluguel — contrato completo com Lei do Inquilinato
• Comodato — empréstimo gratuito de bem
• Comercial — prestação de serviços empresariais
• Honorários — advocatícios com êxito
• NDA — confidencialidade bilateral
Geração instantânea de documentos jurídicos.
Clique aqui ou arraste um ou mais documentos para análise
Formatos aceitos: PDF, PNG, JPG, WEBP (máx 20MB cada · até 5 documentos)
e / ou
Pesquisa livre com a IURIA. Dúvidas de direito, esboço de cláusula ou e-mail, resumo de um conceito, estratégia de caso. Anexe documentos (PDF/imagem) como contexto e use o microfone para ditar.
Enter envia · Shift+Enter quebra linha
`;
// Setup upload drag & drop
const uploadArea = document.getElementById('siaUploadArea');
if (uploadArea) {
uploadArea.onclick = () => document.getElementById('siaFileInput').click();
uploadArea.ondragover = (e) => { e.preventDefault(); uploadArea.classList.add('dragover'); };
uploadArea.ondragleave = () => uploadArea.classList.remove('dragover');
uploadArea.ondrop = (e) => { e.preventDefault(); uploadArea.classList.remove('dragover'); if (e.dataTransfer && e.dataTransfer.files) siaArquivosSelecionar(e.dataTransfer.files); };
}
// Zera a seleção ao (re)entrar na view, pra não vazar estado de navegação anterior.
_siaArquivosSel = [];
_siaThread = null;
_siaContinuarArquivos = [];
siaRenderArquivosSel();
// Zera a conversa da Pesquisa livre ao (re)entrar na view.
_siaPesquisaThread = null;
_siaPesquisaArquivos = [];
siaRenderPesquisaThread();
siaRenderPesquisaArquivos();
}
;
/* ===== SIA - Trocas de aba ===== */
function siaTrocarAba(aba) {
document.querySelectorAll('.sia-tab').forEach(t => t.classList.remove('active'));
document.getElementById('siaTab' + aba.charAt(0).toUpperCase() + aba.slice(1)).classList.add('active');
document.getElementById('siaAbaCriar').classList.toggle('sia-hide', aba !== 'criar');
document.getElementById('siaAbaAnalisar').classList.toggle('sia-hide', aba !== 'analisar');
document.getElementById('siaAbaPesquisa').classList.toggle('sia-hide', aba !== 'pesquisa');
if (aba === 'criar') document.getElementById('siaInput').focus();
if (aba === 'pesquisa') { const p = document.getElementById('siaPesquisaInput'); if (p) p.focus(); }
}
/* ===== SIA - Análise de documentos ===== */
// Análise de documento via IA REAL: usa a edge function classificar-doc
// (OCR com Claude vision) que faz upload + transcreve + extrai dados.
// Mesma infra do cadastro inteligente de cliente. Nada de leitura de binário.
// Estado dos arquivos selecionados (ainda não analisados) na aba Analisar.
let _siaArquivosSel = [];
// Recebe um FileList (input ou drag&drop), acumula no estado e re-renderiza a lista.
function siaArquivosSelecionar(fileList) {
const arr = Array.from(fileList || []);
for (const f of arr) {
// evita duplicar o mesmo arquivo (nome + tamanho)
if (!_siaArquivosSel.some(x => x.name === f.name && x.size === f.size)) {
_siaArquivosSel.push(f);
}
}
siaRenderArquivosSel();
}
// Remove um arquivo da seleção pelo índice e re-renderiza.
function siaRemoverArquivoSel(idx) {
_siaArquivosSel.splice(idx, 1);
siaRenderArquivosSel();
// limpa o input pra permitir re-selecionar o mesmo arquivo depois
const inp = document.getElementById('siaFileInput');
if (inp) inp.value = '';
}
// Lista visível dos arquivos selecionados (chips estilo sia-uploaded-file) com botão remover.
function siaRenderArquivosSel() {
const el = document.getElementById('siaArquivosSel');
if (!el) { siaAtualizarPrevisao(); return; }
if (!_siaArquivosSel.length) { el.innerHTML = ''; siaAtualizarPrevisao(); return; }
let html = '
';
});
el.innerHTML = html;
siaAtualizarPrevisao();
}
// DEPRECATED[dev]: análise por arquivo isolado (1 doc → 1 resultado).
// Substituída pela ANÁLISE CONJUNTA em siaAnalisarEnviar()/siaRenderAnaliseConjunta().
// Mantida apenas como fallback histórico; não é mais chamada pela aba Analisar.
async function siaAnalisarArquivo(file, instrucao) {
if (!file) return;
const maxSize = 20 * 1024 * 1024;
if (file.size > maxSize) { toast('Arquivo muito grande. Máx 20MB.', 'danger'); return; }
const ext = (file.name.split('.').pop() || '').toLowerCase();
if (!['pdf', 'png', 'jpg', 'jpeg', 'webp'].includes(ext)) {
toast('Pra análise por IA, envie PDF ou imagem (PNG/JPG). DOCX/TXT em breve.', 'warning'); return;
}
const container = document.getElementById('siaArquivosAnalisados');
const resultEl = document.getElementById('siaAnaliseResultado');
// Append por arquivo: cada arquivo ganha seu próprio bloco de status e de resultado,
// identificados por um id único, para que análises sucessivas NÃO se sobrescrevam.
const uid = 'sia_an_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7);
const statusEl = document.createElement('div');
statusEl.id = uid + '_status';
statusEl.innerHTML = '
📄' + escape(file.name) + '' + (file.size / 1024).toFixed(1) + ' KB IA lendo o documento… (~5-10s)
';
toast('Erro ao analisar: ' + (err.message || ''), 'danger');
} finally {
// 3) Limpa o arquivo temporário (best-effort)
if (storagePath) { try { await sb.storage.from('documentos').remove([storagePath]); } catch (e) {} }
}
}
// Glue da aba Analisar: ANÁLISE CONJUNTA.
// O usuário pode anexar 1+ documentos E/OU colar 1 link e escrever uma
// Observação. Tudo é enviado de UMA vez à edge `analisar-conjunto`, que
// cruza as fontes e devolve UM relatório único (prós/contras/pegadinhas)
// + o custo (BRL) da pesquisa. Não há mais loop de resultados separados.
const SIA_MAX_DOCS = 5;
// ===== Previsão de PREÇO (estimativa ANTES de rodar) =====
// A previsão agora é SERVER-SIDE: manda só metadados (ext/bytes dos docs,
// tamanho da observação, se há link, chars de histórico) à RPC
// rpc_estimar_preco_analise, que devolve SÓ o `preco` (já com spread) — e,
// pro dono, o custo real. O fator/câmbio/preço-por-token NUNCA vêm ao front.
// Helper: monta os metadados dos docs (sem subir nada).
function siaDocsMeta(arquivos) {
return (arquivos || []).map(f => ({ ext: (f.name.split('.').pop() || '').toLowerCase(), bytes: f.size }));
}
// Chama a RPC e devolve { preco, custo_real? } ou null em caso de falha.
async function siaEstimarPrecoRPC({ arquivos = [], instrucao = '', temLink = false, histChars = 0 } = {}) {
try {
const { data, error } = await sb.rpc('rpc_estimar_preco_analise', {
docs: siaDocsMeta(arquivos),
instrucao_chars: (instrucao || '').length,
tem_link: !!temLink,
hist_chars: histChars || 0,
});
if (error || !data) return null;
return data; // { owner, preco, custo_real? }
} catch (e) { return null; }
}
// Atualiza o texto da previsão perto do botão Analisar.
async function siaAtualizarPrevisao() {
const el = document.getElementById('siaPrevisaoCusto');
if (!el) return;
const instrucao = (document.getElementById('siaAnalisarInstrucao')?.value || '').trim();
const url = (document.getElementById('siaAnalisarLink')?.value || '').trim();
const arquivos = _siaArquivosSel.slice();
if (!arquivos.length && !url) { el.textContent = ''; el.style.display = 'none'; return; }
const est = await siaEstimarPrecoRPC({ arquivos, instrucao, temLink: !!url });
if (!est) { el.textContent = ''; el.style.display = 'none'; return; }
let txt = 'Previsão de custo: ~' + fmtBRL(est.preco);
if (est.owner && typeof est.custo_real === 'number') txt += ' · (real ~' + fmtBRL(est.custo_real) + ')';
el.textContent = txt;
el.style.display = '';
}
// ===== Thread de continuação (multi-turn) =====
// Acumula o contexto da conversa de análise para os turnos "Continuar análise".
// `mensagens` é o histórico no formato {role:'user'|'assistant', conteudo}
// que será reenviado à edge a cada continuação. `temDoc/temLink` é só meta.
let _siaThread = null; // { mensagens: [{role, conteudo}], turnos: [...] } ou null
async function siaAnalisarEnviar() {
const instrucao = (document.getElementById('siaAnalisarInstrucao')?.value || '').trim();
const linkEl = document.getElementById('siaAnalisarLink');
const url = (linkEl?.value || '').trim();
const arquivos = _siaArquivosSel.slice();
// Validação: precisa de ao menos uma fonte (doc ou link).
if (!arquivos.length && !url) {
toast('Envie um documento ou cole um link para analisar.', 'warning');
return;
}
if (url && !/^https?:\/\/.+/i.test(url)) {
toast('Cole um link válido começando com http:// ou https://', 'warning');
return;
}
if (await _iaGate()) return; // teto de custo de IA (trial)
if (arquivos.length > SIA_MAX_DOCS) {
toast('Máximo de ' + SIA_MAX_DOCS + ' documentos por análise. Remova alguns.', 'warning');
return;
}
// Valida extensão/tamanho dos documentos antes de subir.
const maxSize = 20 * 1024 * 1024;
for (const f of arquivos) {
const ext = (f.name.split('.').pop() || '').toLowerCase();
if (!['pdf', 'png', 'jpg', 'jpeg', 'webp'].includes(ext)) {
toast('Documento "' + f.name + '": envie PDF ou imagem (PNG/JPG/WEBP).', 'warning'); return;
}
if (f.size > maxSize) { toast('Documento "' + f.name + '" passa de 20MB.', 'danger'); return; }
}
const btn = document.getElementById('siaAnalisarBtn');
const btnTxt = btn ? btn.textContent : '';
if (btn) { btn.disabled = true; }
const container = document.getElementById('siaArquivosAnalisados');
const resultEl = document.getElementById('siaAnaliseResultado');
resultEl.innerHTML = '';
// Linha de status: lista o que será cruzado.
let statusHtml = '
🧩Análise conjunta';
if (arquivos.length) statusHtml += '' + arquivos.length + ' documento(s)';
if (url) statusHtml += '+ 1 link';
statusHtml += ' IA cruzando as fontes… (~10-25s)
';
container.innerHTML = statusHtml;
const tempPaths = [];
try {
if (btn) btn.textContent = 'Enviando documentos…';
// 1) Sobe cada documento pra um path temporário no Storage.
const documentos = [];
for (let i = 0; i < arquivos.length; i++) {
const file = arquivos[i];
const nomeLimpo = file.name.replace(/[^\w.\-]+/g, '_').slice(0, 200);
const sp = `_sia_temp_${Date.now()}_${Math.random().toString(36).slice(2, 7)}_${nomeLimpo}`;
const { error: errUp } = await sb.storage.from('documentos').upload(sp, file, {
cacheControl: '60', upsert: false, contentType: file.type || undefined,
});
if (errUp) throw new Error('Falha ao enviar "' + file.name + '": ' + errUp.message);
tempPaths.push(sp);
documentos.push({ storage_path: sp, mime_type: file.type || null });
}
// 2) UMA chamada à edge de análise conjunta.
if (btn) btn.textContent = 'Analisando…';
const { data: { session } } = await sb.auth.getSession();
const r = await fetch(`${SUPABASE_URL}/functions/v1/analisar-conjunto`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + (session?.access_token || '') },
body: JSON.stringify({ documentos, url: url || undefined, instrucao: instrucao || undefined }),
});
const ai = await r.json();
if (!r.ok) throw new Error(ai.error || 'IA falhou');
// 3) Render do resultado único.
const lidos = (ai.fontes && ai.fontes.documentos_lidos) || 0;
const linkLido = !!(ai.fontes && ai.fontes.link_lido);
let resumoFontes = lidos + ' documento(s)' + (linkLido ? ' + 1 link' : '');
container.innerHTML = '
✅Análise conjunta concluída' + escape(resumoFontes) + 'CRUZADA POR IA
';
resultEl.innerHTML = siaRenderAnaliseConjunta(ai);
// 3b) Inicia a thread de continuação (multi-turn). O histórico guarda a
// PERGUNTA do advogado (observação) e a ANÁLISE da IA como mensagens; cada
// "Continuar" reenvia esse histórico + a nova pergunta/anexos à edge.
_siaThread = {
mensagens: [
{ role: 'user', conteudo: 'Análise conjunta (fontes anexadas: ' + resumoFontes + ').' + (instrucao ? '\n\nObservação do advogado: ' + instrucao : '') },
{ role: 'assistant', conteudo: String(ai.analise_markdown || '') },
],
};
siaRenderThread();
resultEl.scrollIntoView({ behavior: 'smooth' });
toast('Análise conjunta concluída!', 'success');
// 4) Limpa a seleção (a análise fica no painel).
_siaArquivosSel = [];
siaRenderArquivosSel();
const inp = document.getElementById('siaFileInput');
if (inp) inp.value = '';
siaAtualizarPrevisao();
} catch (err) {
container.innerHTML = '
';
toast('Erro ao analisar: ' + (err.message || ''), 'danger');
} finally {
// 5) Limpa os arquivos temporários do Storage (best-effort).
if (tempPaths.length) { try { await sb.storage.from('documentos').remove(tempPaths); } catch (e) {} }
if (btn) { btn.disabled = false; btn.textContent = btnTxt || 'Analisar'; }
}
}
// Chip de custo da pesquisa (regras de exibição CENTRAIS).
// O PREÇO já vem PRONTO da edge em `ai.preco` (preço final em BRL = custo real
// × spread, calculado no servidor). O front NÃO multiplica nada.
// - Usuário comum: a edge manda SÓ `ai.preco`. Não há custo real nem fator.
// - Dono (George): a edge manda também `ai.custo_real` e `ai.lucro` — só então
// o chip mostra a decomposição. (O JWT do dono é validado no servidor.)
// `opts.chipId` permite reusar com id único nos turnos de continuação.
function siaCustoChipHtml(ai, opts) {
if (typeof ai.preco !== 'number') return '';
const id = (opts && opts.chipId) ? opts.chipId : 'siaCustoPesquisa';
let s = 'Custo desta pesquisa: ' + fmtBRL(ai.preco);
if (typeof ai.tempo_ms === 'number') s += ' · ' + (ai.tempo_ms / 1000).toFixed(1) + 's';
if (typeof ai.custo_real === 'number') {
const lucro = (typeof ai.lucro === 'number') ? ai.lucro : (ai.preco - ai.custo_real);
s += ' · real ' + fmtBRL(ai.custo_real) + ' · lucro ' + fmtBRL(lucro) + '';
}
s += '';
return s;
}
// Renderiza a análise CONJUNTA (markdown único + custo da pesquisa).
function siaRenderAnaliseConjunta(ai) {
const md = String(ai.analise_markdown || '').trim();
let html = '
';
// Cabeçalho: título + custo como chip discreto alinhado à direita.
html += '
'+ic('puzzle')+' Análise
';
html += siaCustoChipHtml(ai);
html += '
';
if (ai.titulo_link) html += '
Link: ' + escape(ai.titulo_link) + '
';
// Avisos de fontes que não puderam ser lidas (não derrubam a análise).
const avisos = (ai.fontes && Array.isArray(ai.fontes.avisos)) ? ai.fontes.avisos : [];
if (avisos.length) {
html += '
Fonte(s) não lida(s):
';
avisos.forEach(a => { html += '
' + escape(String(a)) + '
'; });
html += '
';
}
html += '
' + siaMarkdownToHtml(md) + '
';
html += '
⚠️ Análise automática por IA a partir das fontes enviadas — confira os documentos originais, o edital/anúncio e a matrícula atualizada antes de decidir.
';
html += '';
html += '
';
return html;
}
// Limpa o resultado, o status e a thread de continuação.
function siaLimparAnalise() {
const r = document.getElementById('siaAnaliseResultado'); if (r) r.innerHTML = '';
const a = document.getElementById('siaArquivosAnalisados'); if (a) a.innerHTML = '';
const t = document.getElementById('siaThread'); if (t) t.innerHTML = '';
_siaThread = null;
_siaContinuarArquivos = [];
}
// ===== Continuação da análise (conversa multi-turn) =====
// Estado dos arquivos anexados ao PRÓXIMO turno de continuação.
let _siaContinuarArquivos = [];
// Renderiza a thread (turnos de continuação já respondidos) + a caixa de
// "Continuar análise" (textarea + anexar docs + botão). Ids/classes claros
// pro design refinar depois: #siaThread, #siaContinuarMsg, #siaContinuarBtn.
function siaRenderThread() {
const el = document.getElementById('siaThread');
if (!el) return;
if (!_siaThread) { el.innerHTML = ''; return; }
let html = '';
// Turnos de continuação já respondidos (a análise inicial fica em #siaAnaliseResultado).
const turnos = _siaThread.turnos || [];
turnos.forEach((tn, i) => {
html += '
';
// Pergunta do advogado (alinhada à direita, rótulo discreto "Você").
html += '
';
html += 'Você';
html += '
' + escape(tn.pergunta) + '
';
if (tn.anexos) html += '+ ' + tn.anexos + ' anexo(s)';
html += '
';
// Resposta da IURIA (rótulo + markdown no mesmo estilo do resultado).
html += '
';
html += ''+ic('puzzle')+' IURIA';
const chip = siaCustoChipHtml(tn.ai, { chipId: 'siaCustoCont' + i });
if (chip) html += '
' + chip + '
';
const av = (tn.ai.fontes && Array.isArray(tn.ai.fontes.avisos)) ? tn.ai.fontes.avisos : [];
if (av.length) {
html += '
';
});
// Compositor de continuação (rodapé da análise).
html += '
';
html += '';
html += '';
html += '';
html += '
';
html += '';
html += '';
html += '';
html += '';
html += '
';
el.innerHTML = html;
siaRenderContinuarArquivosSel();
}
// Lista dos arquivos anexados ao próximo turno de continuação.
function siaRenderContinuarArquivosSel() {
const el = document.getElementById('siaContinuarArquivosSel');
if (!el) return;
if (!_siaContinuarArquivos.length) { el.innerHTML = ''; siaAtualizarPrevisaoContinuar(); return; }
let html = '';
_siaContinuarArquivos.forEach((f, i) => {
html += '
';
toast('Erro ao analisar o link: ' + (err.message || ''), 'danger');
}
}
// Renderiza a análise de link (markdown simples → HTML) no painel de resultado.
function siaRenderAnaliseLink(url, ai) {
const md = String(ai.analise || '').trim();
let html = '
Analisado em ' + (ai.tempo_ms / 1000).toFixed(1) + 's
';
html += '
⚠️ Análise automática por IA a partir do texto da página — confira o edital/anúncio original e a matrícula antes de decidir.
';
html += '';
html += '
';
return html;
}
/* ============================================================================
PREPARAÇÃO PARA AUDIÊNCIA (nome PROVISÓRIO — trocar PREP_AUDIENCIA_LABEL)
----------------------------------------------------------------------------
Inspirada no NotebookLM, 100% nativa. O advogado escolhe um processo que JÁ
TEM a íntegra na caixa + o tipo de situação oral; a edge `preparar-audiencia`
lê os autos e devolve um material de preparação ESTRUTURADO (JSON), que é
renderizado em seções + um MAPA MENTAL em SVG. Custo segue o padrão de spread
5x: o front mostra o valor COBRADO; só o dono vê a decomposição real/lucro.
Funções/ids têm prefixo `prep` pra não colidir com o resto do app.
============================================================================ */
// Rótulo da feature em UM só lugar (o George vai renomear). prepAplicarRotulo()
// reescreve o texto na navbar (desktop), na bottomnav (mobile) e no título da view.
const PREP_AUDIENCIA_LABEL = 'Preparação';
const PREP_AUDIENCIA_LABEL_LONGO = 'Preparação para audiência';
function prepAplicarRotulo() {
try {
document.querySelectorAll('.nav-btn[data-view="preparacao"] .nav-label')
.forEach(el => { el.textContent = PREP_AUDIENCIA_LABEL; });
document.querySelectorAll('#bottomNav .bottomnav-item[data-view="preparacao"] span, #bottomNavSheetGrid .bottomnav-sheet-item[data-view="preparacao"] span')
.forEach(el => { el.textContent = PREP_AUDIENCIA_LABEL; });
} catch (e) { /* navbar pode ainda não existir */ }
}
// Tipos de situação oral (4). value = o que a edge espera em tipo_situacao.
const PREP_SITUACOES = [
{ value: 'instrucao', label: 'Audiência de instrução', desc: 'Colher e contraditar prova oral (testemunhas, depoimentos).' },
{ value: 'despacho', label: 'Despacho com o juiz', desc: 'Sustentação oral / audiência de conciliação / gabinete.' },
{ value: 'delegacia', label: 'Delegacia', desc: 'Acompanhar oitiva / depoimento na fase investigativa.' },
{ value: 'orientacao', label: 'Orientação ao cliente', desc: 'Conversa de orientação em linguagem acessível.' },
];
// Polos (mesmo vocabulário do gerar-peticao).
const PREP_POLOS = [
['autor', 'Autor / Exequente / Requerente'],
['reu', 'Réu / Executado / Requerido'],
['terceiro', 'Terceiro interessado'],
['assistente', 'Assistente'],
];
// Estado da view.
let _prepProcessos = []; // [{id, numero, label, clienteNome, temIntegra, nDocs, polo}]
let _prepSelProcId = null; // processo escolhido
let _prepTipo = 'instrucao';
let _prepPolo = ''; // pré-preenchido do caixa
let _prepResultado = null; // último JSON de preparacao (pra imprimir)
let _prepResultadoMeta = null; // { processoLabel, tipoLabel, polo, preco, ... }
// Entrada "por número" (NPU): baixa a íntegra e prepara automaticamente.
let _prepModo = 'acervo'; // 'acervo' (processo já baixado) | 'numero' (NPU)
let _prepNpuFilaId = null; // id do item protocolo_fila tipo='integra' enfileirado
let _prepNpuProcId = null; // processo_id resolvido/criado pro NPU
let _prepNpuPoll = null; // handle do setInterval do polling
let _prepNpuT0 = 0; // início do polling (pra timeout)
const PREP_NPU_TIMEOUT_MS = 5 * 60 * 1000; // ~5 min de paciência
const PREP_NPU_POLL_MS = 12 * 1000; // checa a cada ~12s
views.preparacao = async (main) => {
prepAplicarRotulo();
main.innerHTML = `
${escape(PREP_AUDIENCIA_LABEL_LONGO)}
A IA lê os autos da caixa e prepara o material para a situação oral: resumo, linha do tempo, pontos fortes e fracos, roteiro, perguntas e mapa mental.
Cole o NPU. Se o processo ainda não estiver na caixa, a IA baixa os autos do tribunal e prepara em seguida.
${PREP_SITUACOES.map(s => `
`).join('')}
`;
// Reset de estado ao (re)entrar.
_prepSelProcId = null;
_prepPolo = '';
_prepResultado = null;
_prepResultadoMeta = null;
_prepModo = 'acervo';
if (_prepNpuPoll) { clearInterval(_prepNpuPoll); _prepNpuPoll = null; }
_prepNpuFilaId = null;
_prepNpuProcId = null;
document.getElementById('prepResultado').innerHTML = '';
prepSetModo('acervo');
// Carrega a lista de processos com flag de íntegra (assíncrono).
await prepCarregarProcessos();
// fecha o autocomplete ao clicar fora (remove antes pra não empilhar em re-entradas)
document.removeEventListener('click', prepFecharListaSeFora);
document.addEventListener('click', prepFecharListaSeFora);
};
// Fecha o dropdown de autocomplete quando o clique é fora do campo/lista.
function prepFecharListaSeFora(e) {
const lista = document.getElementById('prepListaProc');
const busca = document.getElementById('prepBusca');
if (!lista || !busca) { document.removeEventListener('click', prepFecharListaSeFora); return; }
if (!lista.contains(e.target) && e.target !== busca) lista.style.display = 'none';
}
/* ──────────────────────────────────────────────────────────────────────────
ENTRADA "POR NÚMERO" (NPU): baixa a íntegra e prepara automaticamente.
Fluxo: (1) resolve/cria processo_id → (2) se já tem docs na caixa, prepara
direto; senão enfileira a íntegra REUSANDO o mesmo insert do "Baixar íntegra"
→ (3) polling até a íntegra chegar (status='integra_baixada' OU docs no
processo_documentos) → (4) chama prepGerar() automaticamente. Caminho B:
processo fora do acervo / captcha → instrui o advogado e oferece tentar de novo.
────────────────────────────────────────────────────────────────────────── */
// Alterna entre os dois modos de escolher o processo.
function prepSetModo(modo) {
_prepModo = (modo === 'numero') ? 'numero' : 'acervo';
document.querySelectorAll('#prepModoTabs .prep-tipo-btn').forEach(b => b.classList.toggle('active', b.dataset.modo === _prepModo));
const acervo = document.getElementById('prepModoAcervo');
const numero = document.getElementById('prepModoNumero');
if (acervo) acervo.style.display = (_prepModo === 'acervo') ? '' : 'none';
if (numero) numero.style.display = (_prepModo === 'numero') ? '' : 'none';
// Cada modo controla o processo selecionado de forma independente: ao trocar,
// limpa a seleção pra não preparar o processo errado.
_prepSelProcId = null;
const btn = document.getElementById('prepBtnGerar'); if (btn) btn.disabled = true;
}
// Só dígitos do NPU.
function prepNpuDigitos(s) { return (s || '').replace(/\D/g, ''); }
// Máscara canônica NNNNNNN-DD.AAAA.J.TR.OOOO (só se tiver 20 dígitos).
function prepNpuMascara(s) {
const d = prepNpuDigitos(s);
if (d.length !== 20) return (s || '').trim();
return d.replace(/^(\d{7})(\d{2})(\d{4})(\d)(\d{2})(\d{4})$/, '$1-$2.$3.$4.$5.$6');
}
// Deriva tribunal/sistema pelo código J·TR (pos 13-15) — mesma lógica de
// pedirIntegraProcesso/iaPedirIntegra. TJSP=826 (esaj), TJSC=824 (eproc-tjsc).
// Para os demais (PJe), deixa nulo: o watcher_integra roteia pelo catálogo.
function prepTribunalSistemaPorNpu(npuDig) {
const jtr = (npuDig && npuDig.length === 20) ? npuDig.substring(13, 16) : '';
if (jtr === '826') return { tribunal: 'TJSP', sistema: 'esaj' };
if (jtr === '824') return { tribunal: 'TJSC', sistema: 'eproc-tjsc' };
return { tribunal: null, sistema: null };
}
// Feedback ao digitar o NPU (valida 20 dígitos; mostra a máscara canônica).
function prepNpuOnInput(v) {
const hint = document.getElementById('prepNpuHint');
const d = prepNpuDigitos(v);
if (!hint) return;
if (!d) { hint.style.color = 'var(--text-muted)'; hint.textContent = 'Cole o NPU. Se o processo ainda não estiver na caixa, a IA baixa os autos do tribunal e prepara em seguida.'; return; }
if (d.length < 20) { hint.style.color = 'var(--text-muted)'; hint.textContent = `${d.length}/20 dígitos…`; return; }
if (d.length > 20) { hint.style.color = 'var(--warning)'; hint.textContent = 'NPU tem mais de 20 dígitos — confira.'; return; }
hint.style.color = 'var(--text-muted)';
hint.textContent = prepNpuMascara(d);
}
// Mensagem de status do modo "por número".
function prepNpuSetStatus(html, tipo) {
const el = document.getElementById('prepNpuStatus');
if (!el) return;
const cor = tipo === 'erro' ? 'var(--danger)' : (tipo === 'ok' ? 'var(--success)' : 'var(--primary)');
const borda = tipo === 'aviso' ? 'var(--warning)' : cor;
el.innerHTML = `
${html}
`;
}
// Conta docs na caixa pro processo_id (>0 = íntegra presente).
async function prepContarDocs(procId) {
try {
const { count } = await sb.from('processo_documentos').select('id', { count: 'exact', head: true }).eq('processo_id', procId);
return count || 0;
} catch (e) { return 0; }
}
// Resolve o processo_id pra um NPU: procura em processos (por dígitos ou
// máscara); se não existir, cria um registro mínimo (numero=NPU). criado_por /
// escritorio_id são preenchidos por trigger; RLS por escritório se aplica.
async function prepResolverProcessoId(npuDig, npuMask) {
// 1) tenta achar por número (com e sem máscara). Compara por dígitos pra ser
// tolerante a registros salvos com formatação diferente.
try {
const { data } = await sb.from('processos')
.select('id,numero')
.or(`numero.eq.${npuMask},numero.eq.${npuDig}`)
.limit(50);
const hit = (data || []).find(p => prepNpuDigitos(p.numero) === npuDig);
if (hit) return hit.id;
} catch (e) { console.warn('prepResolverProcessoId busca:', e); }
// 2) não existe → cria contêiner mínimo. Usa colunas cruas (sem toDb) pra
// casar com o insert de processo do auto-cadastro de citação.
const { tribunal, sistema } = prepTribunalSistemaPorNpu(npuDig);
const proc = { numero: npuMask, status: 'Em andamento' };
if (tribunal) proc.tribunal = tribunal;
if (sistema) proc.sistema = sistema;
const { data, error } = await sb.from('processos').insert([proc]).select('id').single();
if (error) throw new Error('Não consegui criar o processo: ' + error.message);
return data.id;
}
// Botão "Baixar e preparar".
async function prepBaixarEPreparar() {
const input = document.getElementById('prepNpu');
const npuDig = prepNpuDigitos(input ? input.value : '');
if (npuDig.length !== 20) { toast('Digite um NPU válido (20 dígitos).', 'warning'); return; }
const npuMask = prepNpuMascara(npuDig);
const btn = document.getElementById('prepBtnBaixar');
if (btn) { btn.disabled = true; btn.textContent = 'Verificando…'; }
// Limpa qualquer polling anterior + resultado.
if (_prepNpuPoll) { clearInterval(_prepNpuPoll); _prepNpuPoll = null; }
_prepNpuFilaId = null;
const resBox = document.getElementById('prepResultado'); if (resBox) resBox.innerHTML = '';
prepNpuSetStatus('Procurando o processo…');
try {
// 1) resolve/cria processo_id
const procId = await prepResolverProcessoId(npuDig, npuMask);
_prepNpuProcId = procId;
// 2) já tem íntegra? → prepara direto
const nDocs = await prepContarDocs(procId);
if (nDocs > 0) {
prepNpuSetStatus(`Íntegra já está na caixa (${nDocs} documento(s)). Preparando…`, 'ok');
prepPrepararProcId(procId, npuMask);
if (btn) { btn.disabled = false; btn.textContent = 'Baixar e preparar'; }
return;
}
// 3) não tem → enfileira a íntegra (MESMO insert do "Baixar íntegra")
const { tribunal, sistema } = prepTribunalSistemaPorNpu(npuDig);
// Reusa a trava do botão de íntegra: não duplica pedido pendente.
const { data: jaFila } = await sb.from('protocolo_fila')
.select('id,status').eq('tipo', 'integra').eq('processo_id', procId).eq('status', 'aguardando_integra');
let filaId = (jaFila && jaFila.length) ? jaFila[0].id : null;
if (!filaId) {
const { data: ins, error } = await sb.from('protocolo_fila').insert([{
tipo: 'integra',
processo_id: procId,
numero_mascara: npuMask,
numero_processo: npuDig,
grau: '1g',
tribunal,
sistema,
status: 'aguardando_integra',
observacao: 'Pedido pela Preparação (por número) — baixar e preparar. [gesto:app]',
}]).select('id').single();
if (error) throw new Error('Não consegui enfileirar a íntegra: ' + error.message);
filaId = ins.id;
}
_prepNpuFilaId = filaId;
// 4) polling até a íntegra chegar
prepNpuSetStatus(
'Baixando os autos do tribunal… pode levar alguns minutos. '
+ 'Mantenha o tribunal logado no Chrome :9222. Esta aba prepara o material assim que a íntegra chegar.',
'info');
_prepNpuT0 = Date.now();
_prepNpuPoll = setInterval(() => prepNpuPollar(npuMask), PREP_NPU_POLL_MS);
prepNpuPollar(npuMask); // checa já
} catch (e) {
console.error('prepBaixarEPreparar:', e);
prepNpuSetStatus('Erro: ' + escape(e && e.message ? e.message : String(e)), 'erro');
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Baixar e preparar'; }
}
}
// Polling do item de íntegra. Pronto quando status='integra_baixada' OU já há
// docs no processo_documentos. Caminho B: detecta erro de acervo/captcha e
// instrui o advogado. Timeout generoso.
async function prepNpuPollar(npuMask) {
if (!_prepNpuFilaId && !_prepNpuProcId) return;
// (a) docs já chegaram? (cobre engines que gravam docs antes do patch de status)
if (_prepNpuProcId) {
const nDocs = await prepContarDocs(_prepNpuProcId);
if (nDocs > 0) { prepNpuConcluir(npuMask, nDocs); return; }
}
// (b) estado do item na fila
let row = null;
if (_prepNpuFilaId) {
const { data } = await sb.from('protocolo_fila')
.select('status,resultado').eq('id', _prepNpuFilaId).maybeSingle();
row = data || null;
}
if (row) {
const r = row.resultado || {};
// Caminho B / sem engine: tribunal sem cobertura automática.
if (r.integra_nao_suportada) {
prepNpuPararPolling();
prepNpuSetStatus(
'Não dá pra baixar a íntegra deste tribunal automaticamente. '
+ escape(r.mensagem || 'Baixe os autos manualmente no tribunal e anexe à caixa do processo; depois volte aqui.'),
'aviso');
return;
}
// Erro com mensagem (ex.: fora do acervo / captcha pendente no PJe-KZ).
const motivo = r.erro || r.motivo || '';
if (row.status === 'erro' || /fora do acervo|captcha|consulta processual/i.test(motivo)) {
prepNpuPararPolling();
prepNpuCaminhoB(npuMask, motivo);
return;
}
// Íntegra concluída.
if (row.status === 'integra_baixada') {
const nDocs = _prepNpuProcId ? await prepContarDocs(_prepNpuProcId) : 0;
prepNpuConcluir(npuMask, nDocs);
return;
}
}
// (c) timeout: provavelmente preso no caminho B (captcha) ou tribunal sem login.
if (Date.now() - _prepNpuT0 > PREP_NPU_TIMEOUT_MS) {
prepNpuPararPolling();
prepNpuCaminhoB(npuMask, 'A íntegra não chegou no tempo esperado.');
}
}
function prepNpuPararPolling() {
if (_prepNpuPoll) { clearInterval(_prepNpuPoll); _prepNpuPoll = null; }
}
// Íntegra pronta → dispara a preparação automaticamente.
async function prepNpuConcluir(npuMask, nDocs) {
prepNpuPararPolling();
if (!_prepNpuProcId) { prepNpuSetStatus('Íntegra baixada, mas não localizei o processo. Recarregue e tente de novo.', 'erro'); return; }
// Se por acaso ainda não há docs (patch de status veio antes), espera o
// próximo ciclo curto antes de preparar.
if (!nDocs) {
const c = await prepContarDocs(_prepNpuProcId);
if (!c) { prepNpuSetStatus('Íntegra marcada como baixada — aguardando os documentos aparecerem na caixa…', 'info'); _prepNpuT0 = Date.now(); _prepNpuPoll = setInterval(() => prepNpuPollar(npuMask), PREP_NPU_POLL_MS); return; }
nDocs = c;
}
prepNpuSetStatus(`Íntegra baixada (${nDocs} documento(s)). Preparando o material…`, 'ok');
prepPrepararProcId(_prepNpuProcId, npuMask);
}
// Caminho B: processo fora do acervo / captcha → instrução + "tentar de novo".
function prepNpuCaminhoB(npuMask, motivo) {
const msg =
'Este processo precisa de um passo manual. '
+ 'Provavelmente ele está fora do seu acervo no tribunal e a consulta exige captcha. '
+ 'No Chrome :9222, abra o processo na Consulta Processual do tribunal e resolva o captcha. '
+ 'Depois clique em Tentar de novo aqui.'
+ (motivo ? (' Detalhe: ' + escape(String(motivo)) + '') : '')
+ '';
prepNpuSetStatus(msg, 'aviso');
}
// Prepara a partir de um processo_id já com íntegra na caixa (usado pelos dois
// modos): aponta _prepSelProcId e chama o prepGerar() existente. Garante que o
// processo esteja na lista (recarrega se foi criado/baixado agora).
async function prepPrepararProcId(procId, npuMask) {
if (!_prepProcessos.some(p => p.id === procId)) {
await prepCarregarProcessos();
}
let p = _prepProcessos.find(x => x.id === procId);
if (!p) {
// fallback mínimo: monta um item local pra prepGerar não falhar.
const nDocs = await prepContarDocs(procId);
p = { id: procId, numero: npuMask || '', pasta: '', clienteNome: '', temIntegra: nDocs > 0, nDocs, polo: '', _busca: prepNorm(npuMask || '') };
_prepProcessos.unshift(p);
} else {
p.temIntegra = true;
}
_prepSelProcId = procId;
prepGerar();
}
// Carrega processos do tenant + marca quais têm íntegra na caixa
// (processo_integra OU pelo menos 1 doc em processo_documentos) + polo do caixa.
async function prepCarregarProcessos() {
try {
const [procs, integras, docsCount, analises] = await Promise.all([
sb.from('processos').select('id,numero,pasta,cliente_id,posicao_parte').order('updated_at', { ascending: false }).limit(2000),
sb.from('processo_integra').select('processo_id,polo_atuacao,total_docs,status'),
sb.from('processo_documentos').select('processo_id'),
sb.from('processo_analise_ia').select('processo_id,polo'),
]);
const listaProc = procs?.data || [];
// Mapa processo_id -> nº de docs na caixa
const docCount = {};
(docsCount?.data || []).forEach(d => { docCount[d.processo_id] = (docCount[d.processo_id] || 0) + 1; });
const integraBy = {};
(integras?.data || []).forEach(i => { integraBy[i.processo_id] = i; });
const analiseBy = {};
(analises?.data || []).forEach(a => { analiseBy[a.processo_id] = a; });
// Nomes de clientes: usa o cache local (dbGetAll) em vez de um .in() gigante —
// com ~1000+ clientes o .in() estoura o tamanho da URL e a query falha (todos
// apareciam como "sem cliente"). O cache já tem todos os clientes do escritório.
let clientesById = {};
try {
const cli = await dbGetAll('clientes');
(cli || []).forEach(c => { if (c && c.id) clientesById[c.id] = c.nome || ''; });
} catch (e) { /* sem nomes não quebra */ }
_prepProcessos = listaProc.map(p => {
const integ = integraBy[p.id];
const nDocs = docCount[p.id] || (integ ? (integ.total_docs || 0) : 0);
const temIntegra = !!integ || nDocs > 0;
const clienteNome = clientesById[p.cliente_id] || '';
const polo = (analiseBy[p.id]?.polo) || (integ?.polo_atuacao) || p.posicao_parte || '';
return {
id: p.id,
numero: p.numero || '',
pasta: p.pasta || '',
clienteId: p.cliente_id || null,
clienteNome,
temIntegra,
nDocs,
polo,
_busca: prepNorm([p.numero, p.pasta, clienteNome].filter(Boolean).join(' ')),
};
});
// Ordena: com íntegra primeiro, depois por nº de docs desc.
_prepProcessos.sort((a, b) => (b.temIntegra - a.temIntegra) || (b.nDocs - a.nDocs));
const hint = document.getElementById('prepStatus');
const comIntegra = _prepProcessos.filter(p => p.temIntegra).length;
if (hint) hint.textContent = `${comIntegra} processo(s) com íntegra na caixa, prontos para preparar. Os demais aparecem na busca, mas precisam da íntegra antes.`;
} catch (e) {
console.warn('prepCarregarProcessos:', e);
const hint = document.getElementById('prepStatus');
if (hint) hint.textContent = 'Não consegui carregar os processos. Tente recarregar a página.';
}
}
function prepNorm(s) {
return (s || '').toString().normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
}
// Filtra e renderiza o dropdown de autocomplete. Realça os que TÊM íntegra.
function prepFiltrarProcessos(termo) {
const lista = document.getElementById('prepListaProc');
if (!lista) return;
const q = prepNorm(termo);
let res = _prepProcessos;
if (q) res = _prepProcessos.filter(p => p._busca.includes(q));
res = res.slice(0, 40);
if (!res.length) {
lista.innerHTML = '
Nenhum processo encontrado.
';
lista.style.display = 'block';
return;
}
lista.innerHTML = res.map(p => {
// Principal = nome do cliente (é como o advogado pensa); número/pasta vão pra linha secundária.
const principal = p.clienteNome || '(sem cliente)';
const sub = [p.numero || '(sem número)', p.pasta ? ('Pasta ' + p.pasta) : ''].filter(Boolean).join(' · ');
const badge = p.temIntegra
? `✓ íntegra (${p.nDocs} doc${p.nDocs === 1 ? '' : 's'})`
: `sem íntegra`;
return `
${escape(principal)} ${badge}
${sub ? `
${escape(sub)}
` : ''}
`;
}).join('');
lista.style.display = 'block';
}
// Seleciona um processo do dropdown.
function prepEscolherProcesso(id) {
const p = _prepProcessos.find(x => x.id === id);
if (!p) return;
_prepSelProcId = id;
const lista = document.getElementById('prepListaProc'); if (lista) lista.style.display = 'none';
// Mostra o cliente em destaque no input (com o número entre parênteses), pois é como o advogado pensa.
const buscaVal = p.clienteNome
? (p.clienteNome + (p.numero ? ' (' + p.numero + ')' : ''))
: (p.numero || '');
const busca = document.getElementById('prepBusca'); if (busca) busca.value = buscaVal;
// Polo pré-preenchido a partir do caixa.
const poloNorm = prepNormPolo(p.polo);
_prepPolo = (poloNorm && poloNorm !== 'desconhecido') ? poloNorm : '';
const selPolo = document.getElementById('prepPolo'); if (selPolo) selPolo.value = _prepPolo;
const poloHint = document.getElementById('prepPoloHint');
if (poloHint) {
poloHint.textContent = _prepPolo
? 'Pré-preenchido a partir da caixa. Ajuste se necessário.'
: 'Não consta na caixa — escolha o polo (ou deixe a IA inferir).';
}
// Card do processo selecionado.
const sel = document.getElementById('prepProcSel');
if (sel) {
// Cliente em destaque; número + pasta na linha secundária.
const principal = p.clienteNome || '(sem cliente)';
const sub = [p.numero || '(sem número)', p.pasta ? ('Pasta ' + p.pasta) : ''].filter(Boolean).join(' · ');
if (p.temIntegra) {
sel.innerHTML = `
⚠️ Material de auxílio gerado por IA a partir dos autos da caixa. Não substitui o estudo dos autos pelo advogado; confira fatos, datas e provas antes do ato.
';
// Resumo
if (prep.resumo) html += prepSecao('Resumo', `
${escape(String(prep.resumo))}
`, '📋');
// Linha do tempo
if (lt.length) {
let tl = '
';
// Checklist
html += prepSecao('Checklist para o ato', prepLista(prep.checklist, 'Sem itens de checklist.'), '✅');
// Mapa mental SVG
const mm = prep.mapa_mental || {};
if (Array.isArray(mm.nodes) && mm.nodes.length) {
html += prepSecao('Mapa mental', `
${prepMapaMentalSvg(mm)}
`, '🧠');
}
html += '
';
return html;
}
// Cores por grupo do nó (respeita o tema via fallback).
const PREP_GRUPO_COR = {
tese: '#0099B8',
fato: '#2563eb',
prova: '#15803d',
risco: '#b91c1c',
pedido: '#7c3aed',
parte: '#b45309',
};
// Mapa mental radial simples em SVG. nó n1 (ou o 1º) no centro; demais ao redor.
// Usa fundo branco (bom pra impressão) e cores por grupo. Sem dependências.
function prepMapaMentalSvg(mm) {
const nodes = (mm.nodes || []).slice(0, 16);
const edges = (mm.edges || []);
if (!nodes.length) return '';
const W = 760, H = 460, cx = W / 2, cy = H / 2;
// Central: o de id 'n1' se existir, senão o primeiro.
let centralIdx = nodes.findIndex(n => n.id === 'n1');
if (centralIdx < 0) centralIdx = 0;
const central = nodes[centralIdx];
const outros = nodes.filter((_, i) => i !== centralIdx);
const R = Math.min(W, H) / 2 - 70;
const pos = {};
pos[central.id] = { x: cx, y: cy };
const n = outros.length;
outros.forEach((nd, i) => {
const ang = (2 * Math.PI * i) / Math.max(n, 1) - Math.PI / 2;
pos[nd.id] = { x: cx + R * Math.cos(ang), y: cy + R * Math.sin(ang) };
});
const esc = (s) => escape(String(s || ''));
// Quebra label em até 3 linhas curtas.
const wrap = (txt, max) => {
const words = String(txt || '').split(/\s+/);
const lines = []; let cur = '';
for (const w of words) {
if ((cur + ' ' + w).trim().length > max && cur) { lines.push(cur); cur = w; }
else cur = (cur ? cur + ' ' : '') + w;
if (lines.length >= 2) break;
}
if (cur) lines.push(cur);
if (lines.length === 3 && words.join(' ').length > lines.join(' ').length) lines[2] = lines[2].replace(/.{0,2}$/, '…');
return lines.slice(0, 3);
};
let svg = ``;
return svg;
}
// Imprimir / Salvar PDF: janela própria com fundo branco (padrão do app).
function prepImprimir() {
if (!_prepResultado) { toast('Gere a preparação primeiro.', 'warning'); return; }
const meta = _prepResultadoMeta || {};
const w = window.open('', '_blank', 'width=900,height=1000');
if (!w) { toast('Bloqueador de popup ativo — permita popups e tente de novo.', 'warning'); return; }
// Reusa o renderer, mas remove os botões/chip de custo do bloco impresso.
let corpo = prepRenderResultado(_prepResultado, { ...meta, preco: undefined, custo_real: undefined });
// Tira o botão de imprimir do HTML impresso.
corpo = corpo.replace(/