// 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 */}
Dashboard
/
Análises
/
#{shortId(analysis.id)}
{/* Header */}
{/* 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 */}
Linha do tempo
Eventos do processador
);
}
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 (
{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
{c.seguros.map((s, i) => (
{s.nome}
{fmtBRL.format(s.valor)}
))}
)}
);
}
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
{r.criterios.map((c) => (
))}
);
}
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 (
{i < events.length - 1 && }
{EVENT_LABEL[ev.type] || ev.type}
{relativeTime(ev.at)} · {fmtDateTime.format(new Date(ev.at))}
);
})}
);
}
window.AnalysisDetail = AnalysisDetail;