// Root App — auth gate, screen routing, global state, API layer. // API_BASE: // - vazio = same origin (production via Caddy) // - override via window.PERICIEI_API_BASE em config.js (dev sem Caddy) const API_BASE = (window.PERICIEI_API_BASE ?? '').replace(/\/$/, ''); const LS = { TOKEN: 'periciei.token', // JWT do usuário logado TENANT: 'periciei.tenant', USER: 'periciei.user', }; // Fetch wrapper — injeta Authorization: Bearer e trata expiração. function apiFetch(path, opts = {}) { const token = localStorage.getItem(LS.TOKEN); const headers = { ...(opts.headers || {}) }; if (token) headers['Authorization'] = `Bearer ${token}`; return fetch(`${API_BASE}${path}`, { ...opts, headers }); } function App() { // ─── Auth ──────────────────────────────────────────────────────────────── const [token, setToken] = useState(() => localStorage.getItem(LS.TOKEN)); const [user, setUser] = useState(() => { try { return JSON.parse(localStorage.getItem(LS.USER) || 'null'); } catch { return null; } }); // ─── Domain state ──────────────────────────────────────────────────────── // Inicializa vazio. Tudo vem da API — sem mocks vazando pra UI real. const [tenant, setTenant] = useState(() => { try { return JSON.parse(localStorage.getItem(LS.TENANT) || 'null') || null; } catch { return null; } }); const [usage, setUsage] = useState({ credit_balance: 0, can_create_analysis: false }); const [periciaTypes, setPericiaTypes] = useState([]); const [analyses, setAnalyses] = useState([]); const [billing, setBilling] = useState([]); // ─── Navigation ───────────────────────────────────────────────────────── const [screen, setScreen] = useState('dashboard'); // dashboard | analises | settings | analise-detalhe const [activeAnalysisId, setActive] = useState(null); // ─── Modals + drawer ─────────────────────────────────────────────────── const [showNew, setShowNew] = useState(false); const [showBuy, setShowBuy] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false); // PDF protegido: backend devolveu 422 PDF_PASSWORD_REQUIRED/INVALID e // estamos aguardando senha do usuário pra retentar o mesmo upload. const [pdfPasswordPrompt, setPdfPasswordPrompt] = useState(null); // {bundle, error} // ─── Effects: fetch real data; fall back silently if backend offline. ─── useEffect(() => { if (!token) return; const ctrl = new AbortController(); (async () => { try { const me = await apiFetch('/v1/auth/me', { signal: ctrl.signal }); if (me.status === 401) { handleLogout(); return; } if (me.ok) { const data = await me.json(); if (data.tenant) { setTenant(data.tenant); localStorage.setItem(LS.TENANT, JSON.stringify(data.tenant)); } if (data.user) { setUser(data.user); localStorage.setItem(LS.USER, JSON.stringify(data.user)); } } const u = await apiFetch('/v1/usage', { signal: ctrl.signal }); if (u.ok) { const usageData = await u.json(); setUsage((prev) => ({ ...prev, ...usageData })); } const pt = await apiFetch('/v1/pericia-types', { signal: ctrl.signal }); if (pt.ok) { const d = await pt.json(); setPericiaTypes(Array.isArray(d) ? d : []); } const a = await apiFetch('/v1/analyses?limit=50', { signal: ctrl.signal }); if (a.ok) { const data = await a.json(); setAnalyses(Array.isArray(data.items) ? data.items : []); } } catch (_e) { // Backend offline: mantém estado vazio. Mostra empty states. } })(); return () => ctrl.abort(); }, [token]); // ─── Auth actions ──────────────────────────────────────────────────────── const handleAuth = ({ token: newToken, user: newUser, tenant: newTenant }) => { localStorage.setItem(LS.TOKEN, newToken); if (newUser) localStorage.setItem(LS.USER, JSON.stringify(newUser)); if (newTenant) localStorage.setItem(LS.TENANT, JSON.stringify(newTenant)); setToken(newToken); if (newUser) setUser(newUser); if (newTenant) setTenant(newTenant); }; const handleLogout = () => { localStorage.removeItem(LS.TOKEN); localStorage.removeItem(LS.TENANT); localStorage.removeItem(LS.USER); setToken(null); setUser(null); setScreen('dashboard'); }; // ─── Navigation helpers ────────────────────────────────────────────────── const navigate = (id) => { if (id === 'creditos') { setShowBuy(true); return; } setActive(null); setScreen(id); }; const openAnalise = (id) => { setActive(id); setScreen('analise-detalhe'); }; useEffect(() => { const fn = () => setShowBuy(true); window.addEventListener('open-buy', fn); return () => window.removeEventListener('open-buy', fn); }, []); // ─── New analysis submission ──────────────────────────────────────────── // Aceita `pdf_password` opcional pra retry após backend pedir senha de PDF. // Mantém o bundle completo em `pdfPasswordPrompt.bundle` pra retry sem o // usuário re-selecionar o arquivo. const submitNewAnalysis = async (bundle, { pdf_password } = {}) => { const { file, pericia_type_code, requerente_nome, idempotency_key } = bundle; const fd = new FormData(); fd.append('file', file); fd.append('pericia_type_code', pericia_type_code); if (requerente_nome) fd.append('requerente_nome', requerente_nome); if (pdf_password) fd.append('pdf_password', pdf_password); try { const res = await apiFetch('/v1/analyses', { method: 'POST', headers: { 'Idempotency-Key': idempotency_key }, body: fd, }); if (res.status === 402) { setShowNew(false); setPdfPasswordPrompt(null); setShowBuy(true); alert('Saldo insuficiente. Recarregue créditos antes de enviar uma nova análise.'); return; } // PDF protegido — abrir modal de senha mantendo o file no bundle. if (res.status === 422) { const body = await res.json().catch(() => ({})); const code = body?.detail?.code; if (code === 'PDF_PASSWORD_REQUIRED' || code === 'PDF_PASSWORD_INVALID') { setShowNew(false); setPdfPasswordPrompt({ bundle, error: code === 'PDF_PASSWORD_INVALID' ? 'Senha incorreta. Tente novamente.' : null, }); return; } } if (!res.ok) { const body = await res.json().catch(() => ({})); const msg = typeof body.detail === 'string' ? body.detail : (body?.detail?.message || res.status); alert(`Falha ao enviar: ${msg}`); return; } const created = await res.json(); setAnalyses((prev) => [{ ...created, artifacts: { spreadsheet: null, report_pdf: null, memory_md: null }, viability_result: null, criteria_met: null, events: [{ type: 'analysis.queued', at: created.created_at }], }, ...prev]); setUsage((prev) => ({ ...prev, credit_balance: Math.max(0, (prev.credit_balance ?? 0) - 1) })); setShowNew(false); setPdfPasswordPrompt(null); openAnalise(created.id); } catch (e) { alert(`Falha de conexão ao enviar análise: ${e.message || e}`); } }; // ─── Purchase ──────────────────────────────────────────────────────────── const handlePurchase = (pkg, method) => { setShowBuy(false); setTimeout(() => { alert(`Redirecionando para o Asaas…\n\nPacote: ${pkg.credits} créditos · ${fmtBRL.format(pkg.price)}\nMétodo: ${method.toUpperCase()}\n\nApós confirmação, o webhook creditará automaticamente sua conta.`); // Optimistic credit (simulado) — backend real credita via webhook Asaas setUsage((prev) => ({ ...prev, credit_balance: (prev.credit_balance ?? 0) + pkg.credits, last_purchase: { credits: pkg.credits, at: new Date().toISOString() }, })); }, 200); }; // ─── Render: login or app shell ───────────────────────────────────────── if (!token) { return ; } const currentNav = screen === 'analise-detalhe' ? 'analises' : screen === 'comprar' ? 'creditos' : screen; // navega + fecha o drawer no mobile const navigateAndClose = (id) => { navigate(id); setSidebarOpen(false); }; return (
setSidebarOpen(false)} />
setSidebarOpen(true)} right={ <> } />
{screen === 'dashboard' && ( setShowNew(true)} onOpenBuy={() => setShowBuy(true)} onOpenAnalise={openAnalise} /> )} {screen === 'analises' && ( setShowNew(true)} /> )} {screen === 'analise-detalhe' && ( setScreen('dashboard')} onAnalysesUpdate={setAnalyses} /> )} {screen === 'settings' && ( )} {screen === 'admin' && user?.role === 'SUPERADMIN' && ( )}
setShowNew(false)} usage={usage} periciaTypes={periciaTypes} onSubmit={submitNewAnalysis} /> setShowBuy(false)} onPurchase={handlePurchase} /> setPdfPasswordPrompt(null)} onSubmit={submitNewAnalysis} />
); } // ─── Full analyses list (sidebar "Análises") ────────────────────────────── function AnalisesAll({ analyses, onOpenAnalise, onOpenNew }) { const [statusFilter, setStatusFilter] = useState('ALL'); const [q, setQ] = useState(''); const filtered = analyses.filter((a) => { if (statusFilter !== 'ALL' && a.status !== statusFilter) return false; if (q.trim()) { const needle = q.trim().toLowerCase(); const hay = (a.requerente_nome || '') + ' ' + a.id; if (!hay.toLowerCase().includes(needle)) return false; } return true; }); const counts = useMemo(() => { const c = { ALL: analyses.length, COMPLETE: 0, INPROGRESS: 0, FAILED: 0, QUEUED: 0 }; for (const a of analyses) { if (a.status === 'COMPLETE') c.COMPLETE++; else if (a.status === 'FAILED') c.FAILED++; else if (a.status === 'QUEUED') c.QUEUED++; else c.INPROGRESS++; } return c; }, [analyses]); const FILTERS = [ { id: 'ALL', label: 'Todas', n: counts.ALL }, { id: 'COMPLETE', label: 'Concluídas', n: counts.COMPLETE }, { id: 'INPROGRESS', label: 'Em andamento', n: counts.INPROGRESS }, { id: 'QUEUED', label: 'Em fila', n: counts.QUEUED }, { id: 'FAILED', label: 'Falhas', n: counts.FAILED }, ]; return (

Análises

Histórico das análises do escritório.

{FILTERS.map((f) => { const active = statusFilter === (f.id === 'INPROGRESS' ? 'INPROGRESS' : f.id); const onClick = () => setStatusFilter(f.id === 'INPROGRESS' ? 'INPROGRESS' : f.id); return ( ); })}
setQ(e.target.value)} placeholder="Buscar por requerente…" className="w-full h-9 pl-8 pr-3 rounded-md border border-ink-200 bg-white text-[13px] placeholder:text-ink-400 focus:border-navy-800 focus:ring-2 focus:ring-navy-800/15 transition-colors" />
{/* Mobile: lista de cards. Desktop: tabela. */}
{filtered.map((a) => { const inProg = (a.status !== 'COMPLETE' && a.status !== 'FAILED'); return ( ); })} {filtered.length === 0 && (
Nenhuma análise corresponde aos filtros.
)}
{filtered.map((a) => { const inProg = (a.status !== 'COMPLETE' && a.status !== 'FAILED'); return ( onOpenAnalise(a.id)} className="border-b border-ink-100 last:border-0 hover:bg-ink-50 cursor-pointer transition-colors" > ); })} {filtered.length === 0 && ( )}
Data Requerente Tipo Status Viabilidade
{fmtDateTime.format(new Date(a.created_at))} {a.requerente_nome || } Revisional Veículo {a.pericia_version_label} {inProg ? apurando… : }
Nenhuma análise corresponde aos filtros.
); } // Exposes apiFetch globally so other screens (detail, settings) can use it. window.apiFetch = apiFetch; window.API_BASE = API_BASE; ReactDOM.createRoot(document.getElementById('root')).render();