// Analysis detail screen — with polling simulation. const STAGE_ORDER = ['QUEUED', 'EXTRACTING', 'COMPUTING', 'DRAFTING', 'RENDERING', 'COMPLETE']; const STAGE_PRETTY = { QUEUED: 'Aguardando processador', EXTRACTING: 'Extraindo texto do PDF', COMPUTING: 'Calculando taxa, CET e capitalização', DRAFTING: 'Redigindo painel e memória', RENDERING: 'Renderizando planilha e PDF', COMPLETE: 'Análise concluída', }; const EVENT_LABEL = { 'analysis.queued': 'Análise enfileirada', 'stage:extracting:started': 'Extração iniciada', 'stage:extracting:done': 'Extração concluída', 'stage:computing:started': 'Cálculo iniciado', 'stage:computing:done': 'Cálculo concluído', 'stage:drafting:started': 'Redação iniciada', 'stage:drafting:done': 'Redação concluída', 'stage:rendering:started': 'Renderização iniciada', 'stage:rendering:done': 'Renderização concluída', 'analysis.complete': 'Análise concluída', 'analysis.failed': 'Análise falhou', }; function AnalysisDetail({ analysisId, analyses, onBack, onAnalysesUpdate }) { const analysis = analyses.find((a) => a.id === analysisId); // Polling REAL do backend: refetch GET /v1/analyses/{id} a cada 4s enquanto // a análise não tá COMPLETE ou FAILED. Quando completa, atualiza a entrada // no estado global com extracted_data + computed_data + artifacts reais. useEffect(() => { if (!analysisId) return; let cancelled = false; const fetchOnce = async () => { try { const res = await window.apiFetch(`/v1/analyses/${analysisId}`); if (!res.ok || cancelled) return null; const fresh = await res.json(); onAnalysesUpdate((prev) => prev.map((a) => (a.id === analysisId ? { ...a, ...fresh } : a))); return fresh; } catch (_e) { return null; } }; // primeiro fetch imediato pra pegar dados que possam ter chegado antes de abrir fetchOnce(); // polling enquanto in-progress const stopWhen = (s) => s === 'COMPLETE' || s === 'FAILED'; if (analysis && stopWhen(analysis.status)) return; const tick = setInterval(async () => { const fresh = await fetchOnce(); if (fresh && stopWhen(fresh.status)) clearInterval(tick); }, 4000); return () => { cancelled = true; clearInterval(tick); }; }, [analysisId]); if (!analysis) { return
Análise não encontrada.
; } // Adapta os campos da API real (extracted_data / computed_data) pro formato // que esta tela renderiza. Esses objetos só aparecem quando status=COMPLETE. const ext = analysis.extracted_data || {}; const comp = analysis.computed_data || {}; const num = (v) => v == null ? null : (typeof v === 'string' ? parseFloat(v) : v); if (analysis.status === 'COMPLETE' && analysis.extracted_data && !analysis.contrato) { const seguros = (ext.lista_seguros || []).map((s) => ({ nome: s.nome, valor: num(s.valor), seguradora: s.seguradora })); const tarifas = (ext.lista_tarifas || []).reduce((acc, t) => { acc[t.nome] = num(t.valor); return acc; }, {}); analysis.contrato = { banco: ext.requerido, data_contrato: ext.data_contratual, valor_financiado: num(ext.valor_total_financiado_f6), taxa_aa: num(ext.taxa_juros_aa), taxa_am: num(comp.c10_taxa_am), cet_aa: num(ext.cet_aa_declarado), parcelas: ext.qtd_parcelas_regulares, valor_parcela: num(ext.valor_parcela_regular), parcela_balao: ext.parcela_balao, sistema_amortizacao: (ext.sistema_amortizacao_declarado || 'não declarado').toUpperCase(), tarifas, seguros, veiculo: ext.veiculo, layout: ext.layout, }; const tx_media = num(comp.taxa_media_aa); const excesso = num(comp.excesso_medio); analysis.resultados = { taxa_media_bacen_aa: tx_media, excesso_aa: excesso, capitalizacao_diaria: ext.capitalizacao_diaria === 'Sim', reducao_estimada_total: num(comp.reducao_estimada), reducao_estimada_parcela: comp.c12 && comp.reducao_estimada ? num(comp.reducao_estimada) / Number(comp.c12) : null, criterios: [ { id: 'a_taxa_abusiva', label: 'Taxa pactuada > 30% acima da média Bacen', ok: comp.criteria_a_taxa_abusiva, detalhe: tx_media != null ? `${(num(ext.taxa_juros_aa)*100).toFixed(2)}% a.a. vs. ${(tx_media*100).toFixed(2)}% a.a. (${excesso>=0?'+':''}${(excesso*100).toFixed(2)} p.p.)` : null }, { id: 'b_capitalizacao', label: 'Capitalização diária expressa em cláusula', ok: comp.criteria_b_capitalizacao, detalhe: ext.capitalizacao_diaria === 'Sim' ? 'Cláusula identificada no contrato' : 'Sem cláusula expressa' }, { id: 'c_tarifas_seguros', label: 'Tarifas + seguros indevidos > 3% do valor financiado', ok: comp.criteria_c_tarifas, detalhe: comp.c8 ? `R$ ${num(comp.c9).toFixed(2)} / R$ ${num(comp.c8).toFixed(2)} = ${((num(comp.c9)/num(comp.c8))*100).toFixed(2)}%` : null }, ], }; analysis.consideracoes = ext.considerations_paragraph || null; // Alertas operacionais (compatibilidade com mock antigo do render) const alertas = []; if (ext.urgencia_processual) alertas.push({ tipo: 'urgencia', severidade: 'alta', texto: `Urgência processual (${ext.urgencia_processual.tipo_acao}) — prazos correndo.` }); if (ext.plano_atipico) alertas.push({ tipo: 'plano_atipico', severidade: 'media', texto: `Plano atípico — ${ext.parcela_balao ? 'parcela balão de R$ ' + num(ext.parcela_balao.valor).toFixed(2) + ' em ' + ext.parcela_balao.vencimento : 'estrutura atípica detectada'}.` }); if (ext.custodia === 'integra_fraca' || ext.custodia === 'indefinida') alertas.push({ tipo: 'custodia', severidade: 'media', texto: 'Cadeia de custódia frágil — confira via original antes de qualquer providência.' }); if (ext.custodia === 'adulteracao') alertas.push({ tipo: 'custodia', severidade: 'alta', texto: 'Indícios de adulteração — solicitar via original imediatamente.' }); if (ext.divergencia_versoes) alertas.push({ tipo: 'divergencia', severidade: 'media', texto: 'Divergência entre orçamento e cédula — adotada a cédula assinada.' }); if (ext.veiculo?.zero_km && (ext.lista_tarifas || []).some(t => (t.nome || '').toLowerCase().includes('avalia'))) { alertas.push({ tipo: 'zero_km_aval', severidade: 'baixa', texto: 'Veículo zero km com Tarifa de Avaliação — fragilidade jurídica reforçada.' }); } if (ext.refinanciamento) alertas.push({ tipo: 'refi', severidade: 'media', texto: 'Refinanciamento/portabilidade — base pode incluir saldo de operação anterior.' }); analysis.alertas = alertas; } const inProgress = INPROGRESS.has(analysis.status) || analysis.status === 'QUEUED'; const stageIdx = Math.max(0, STAGE_ORDER.indexOf(analysis.status)); const stageTotal = STAGE_ORDER.length - 1; const progressPct = analysis.status === 'COMPLETE' ? 100 : Math.round((stageIdx / stageTotal) * 100); return (
{/* breadcrumb */} {/* Header */}
Análise pericial · Revisional de Financiamento de Veículo {analysis.pericia_version_label}

{analysis.requerente_nome || Requerente não informado}

Criada em {fmtDateTime.format(new Date(analysis.created_at))} {analysis.completed_at && ( <> · Concluída em {fmtDateTime.format(new Date(analysis.completed_at))} )} · #{analysis.id}
{/* progress */} {inProgress && (
{STAGE_PRETTY[analysis.status]}… Estágio {stageIdx} de {stageTotal} · {progressPct}%
{STAGE_ORDER.slice(1).map((s, i) => { const done = i + 1 < stageIdx || analysis.status === 'COMPLETE'; const active = STAGE_ORDER[stageIdx] === s; return (
{s.toLowerCase()}
); })}
)} {analysis.status === 'FAILED' && analysis.error_message && (
Falha no processamento
{analysis.error_message}
)}
{/* Downloads (only when complete) */} {analysis.status === 'COMPLETE' && (
} ext=".xlsx" title="Planilha de cálculo" desc="Cronograma teórico × pactuado, taxa Bacen, CET XIRR e estimativa de redução." href={`/v1/analyses/${analysis.id}/spreadsheet.xlsx`} filename={`analise-${shortId(analysis.id)}-planilha.xlsx`} /> } ext=".pdf" title="Painel de viabilidade" desc="1 página: dados do contrato, critérios atendidos, alertas operacionais." href={`/v1/analyses/${analysis.id}/report.pdf`} filename={`analise-${shortId(analysis.id)}-painel.pdf`} primary /> } ext=".md" title="Memória de cálculo" desc="Detalhamento técnico verificável: fórmulas, seguros listados, fontes citadas." href={`/v1/analyses/${analysis.id}/memory.md`} filename={`analise-${shortId(analysis.id)}-memoria.md`} />
)} {/* Main grid */}
{/* Contract + Results */} {analysis.contrato && analysis.resultados && (

Painel de viabilidade

espelha o PDF gerado
)} {/* Alerts */} {analysis.alertas && analysis.alertas.length > 0 && (

Alertas operacionais

Pontos que exigem atenção do perito antes do protocolo.

    {analysis.alertas.map((al, i) => )}
)} {/* Considerations */} {analysis.consideracoes && (

Considerações

{analysis.consideracoes}

{/* tokens/custo são métricas internas. Backend só envia esses campos pra SUPERADMIN (cost_brl=null pra cliente). Renderiza só quando vier. */} {analysis.cost_brl != null && (
[admin] tokens_input {fmtNum.format(analysis.tokens_input)} tokens_output {fmtNum.format(analysis.tokens_output)} cached {fmtNum.format(analysis.tokens_cached)} custo {fmtBRL.format(analysis.cost_brl)}
)}
)} {/* In-progress placeholder */} {inProgress && !analysis.contrato && (
{STAGE_PRETTY[analysis.status]}
Polling automático a cada 3 segundos. Você pode fechar esta página — a análise continua processando.
)}
{/* Sidebar: timeline */}
); } function DownloadCard({ icon, ext, title, desc, href, filename, primary }) { const [busy, setBusy] = useState(false); const handle = async (e) => { e.preventDefault(); if (busy) return; setBusy(true); try { // Endpoints precisam de Authorization: Bearer — fetch + blob + anchor sintético // pra disparar download mantendo o filename desejado. const res = await window.apiFetch(href); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.detail || `HTTP ${res.status}`); } const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); // Revoke depois de um tick pra Safari/iOS conseguir disparar o save. setTimeout(() => URL.revokeObjectURL(url), 1500); } catch (err) { alert(`Falha ao baixar: ${err.message || err}`); } finally { setBusy(false); } }; return (
{icon}
{ext}
{title}

{desc}

{busy ? <> Baixando… : <> Baixar arquivo}
); } function ContratoBlock({ c }) { // Linhas estáticas só aparecem quando temos valor — evita "R$ —" lotando a UI. // Tarifas são dinâmicas: vêm como dict {nome: valor} extraído do contrato, // então iteramos sobre as chaves reais em vez de assumir slugs fixos. const tarifasList = c.tarifas ? Object.entries(c.tarifas).filter(([_, v]) => v != null) : []; const dataContratoStr = c.data_contrato ? fmtDate.format(new Date(c.data_contrato)) : null; const rows = [ ['Instituição financeira', c.banco], c.contrato_num && ['Nº do contrato', {c.contrato_num}], dataContratoStr && ['Data do contrato', dataContratoStr], c.sistema_amortizacao && ['Sistema de amortização', c.sistema_amortizacao], c.valor_bem != null && ['Valor do bem', fmtBRL.format(c.valor_bem)], c.entrada != null && ['Entrada', fmtBRL.format(c.entrada)], c.valor_financiado != null && ['Valor financiado', fmtBRL.format(c.valor_financiado)], ...tarifasList.map(([nome, valor]) => [nome, fmtBRL.format(valor)]), c.taxa_am != null && ['Taxa pactuada (a.m.)', {fmtPct(c.taxa_am)}], c.taxa_aa != null && ['Taxa pactuada (a.a.)', {fmtPct(c.taxa_aa)}], c.cet_aa != null && ['CET (a.a.)', {fmtPct(c.cet_aa)}], c.parcelas && c.valor_parcela != null && ['Parcelas', `${c.parcelas}x ${fmtBRL.format(c.valor_parcela)}`], ].filter(Boolean); return (
Dados do contrato
{rows.map(([k, v]) => (
{k}
{v}
))}
{c.seguros && c.seguros.length > 0 && (
Seguros embutidos
)}
); } function ResultadosBlock({ r }) { return (
Resultados
Taxa média Bacen (a.a.)
{fmtPct(r.taxa_media_bacen_aa)}
Excesso vs. Bacen
0 ? 'text-danger-700' : 'text-ok-700'}`}> {r.excesso_aa != null ? `${r.excesso_aa >= 0 ? '+' : ''}${(r.excesso_aa * 100).toFixed(2).replace('.', ',')} p.p.` : '—'}
Capitalização diária
{r.capitalizacao_diaria ? SIM : NÃO}
Redução por parcela
{fmtBRL.format(r.reducao_estimada_parcela)}
Redução total estimada
{fmtBRL.format(r.reducao_estimada_total)}
Critérios de viabilidade
); } function AlertRow({ alert }) { const sev = alert.severidade; const styles = sev === 'alta' ? { bar: 'bg-danger-600', text: 'text-danger-700', tag: 'bg-danger-50 text-danger-700 border-danger-200' } : sev === 'media' ? { bar: 'bg-warn-600', text: 'text-warn-700', tag: 'bg-warn-50 text-warn-700 border-warn-200' } : { bar: 'bg-ink-300', text: 'text-ink-700', tag: 'bg-ink-100 text-ink-700 border-ink-200' }; const TYPE_LABEL = { urgencia_processual: 'Urgência processual', custodia_fragil: 'Custódia frágil', plano_atipico: 'Plano atípico', veiculo_zero_km: 'Veículo zero km', refinanciamento: 'Refinanciamento', }; return (
  • {TYPE_LABEL[alert.tipo] || alert.tipo} severidade {sev}

    {alert.texto}

  • ); } function Timeline({ events, now }) { if (!events.length) { return
    Nenhum evento registrado ainda.
    ; } return (
      {events.map((ev, i) => { const isStart = ev.type.endsWith(':started') || ev.type === 'analysis.queued'; const isFail = ev.type === 'analysis.failed'; const isDone = ev.type === 'analysis.complete'; const dot = isFail ? 'bg-danger-600' : isDone ? 'bg-ok-600' : isStart ? 'bg-navy-800' : 'bg-ink-300'; return (
    1. {i < events.length - 1 && }
      {EVENT_LABEL[ev.type] || ev.type}
      {relativeTime(ev.at)} · {fmtDateTime.format(new Date(ev.at))}
    2. ); })}
    ); } window.AnalysisDetail = AnalysisDetail;