// 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
Histórico das análises do escritório.
| 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. | |||||