// Dashboard + New Analysis modal + Buy Credits modal.
// ─── Dashboard ────────────────────────────────────────────────────────────────
function Dashboard({ usage, analyses, onOpenNew, onOpenBuy, onOpenAnalise }) {
const last30 = useMemo(() => {
const m = { COMPLETE: 0, INPROGRESS: 0, FAILED: 0 };
const cutoff = Date.now() - 30 * 86400000;
for (const a of analyses) {
if (new Date(a.created_at).getTime() < cutoff) continue;
if (a.status === 'COMPLETE') m.COMPLETE++;
else if (a.status === 'FAILED') m.FAILED++;
else m.INPROGRESS++;
}
return m;
}, [analyses]);
const recents = analyses.slice(0, 10);
const lowBalance = usage.credit_balance < 3;
return (
{/* Low balance banner */}
{lowBalance && (
Saldo baixo — recarregue antes do próximo prazo.
Restam apenas {usage.credit_balance} créditos. Pagamento por PIX é instantâneo.
)}
{/* Credit card hero */}
Saldo de créditos · não expira
{usage.credit_balance}
créditos disponíveis
{usage.last_purchase
? <>Última recarga: {usage.last_purchase.credits} créditos em {fmtDate.format(new Date(usage.last_purchase.at))} · cada análise consome 1 crédito>
: <>Cada análise consome 1 crédito · créditos não têm prazo de validade>}
{usage.plan_label && (
Pacote atual: {usage.plan_label}
)}
{/* Month stats */}
0 ? 'text-danger-700' : 'text-ink-900'}
/>
{/* Recents table */}
Análises recentes
Últimas 10 análises do escritório.
{recents.length === 0 ? (
) : (
| Data |
Requerente |
Tipo |
Status |
Viabilidade |
ID |
|
{recents.map((a) => (
onOpenAnalise(a.id)}
className="border-b border-ink-100 last:border-0 hover:bg-ink-50 cursor-pointer transition-colors"
>
| {fmtDateTime.format(new Date(a.created_at))} |
{a.requerente_nome || —} |
Revisional Veículo
{a.pericia_version_label}
|
|
|
#{shortId(a.id)} |
|
))}
)}
);
}
function EmptyState({ onOpenNew }) {
return (
Nenhuma análise ainda
Envie seu primeiro contrato em PDF para gerar planilha, painel e memória de cálculo.
);
}
// ─── New Analysis Modal ───────────────────────────────────────────────────────
function NewAnalysisModal({ open, onClose, usage, periciaTypes, onSubmit }) {
const [file, setFile] = useState(null);
const [tipo, setTipo] = useState(periciaTypes[0]?.code || '');
const [requerente, setReq] = useState('');
const [dragOver, setDragOver] = useState(false);
const [error, setError] = useState(null);
const [submitting, setSubmitting] = useState(false);
const inputRef = useRef(null);
useEffect(() => {
if (open) { setFile(null); setReq(''); setError(null); setDragOver(false); }
}, [open]);
const sufficient = usage.credit_balance > 0;
const idempKey = useMemo(() => uuid(), [open]);
const acceptFile = (f) => {
setError(null);
if (!f) return;
if (f.type && f.type !== 'application/pdf' && !f.name.toLowerCase().endsWith('.pdf')) {
setError('Apenas arquivos PDF são aceitos. Converta antes de enviar.');
return;
}
if (f.size > 50 * 1024 * 1024) {
setError('Arquivo excede o limite de 50 MB. Reduza ou divida o PDF.');
return;
}
setFile(f);
};
const submit = () => {
if (!file || !sufficient || submitting) return;
setSubmitting(true);
setTimeout(() => {
onSubmit({
file,
pericia_type_code: tipo,
requerente_nome: requerente.trim() || null,
idempotency_key: idempKey,
});
setSubmitting(false);
}, 600);
};
const fmtBytes = (n) => {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(2)} MB`;
};
return (
{/* Dropzone */}
{ e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => { e.preventDefault(); setDragOver(false); acceptFile(e.dataTransfer.files?.[0]); }}
onClick={() => inputRef.current?.click()}
className={`relative border-2 border-dashed rounded-lg px-5 py-8 text-center cursor-pointer transition-colors ${
dragOver
? 'border-navy-800 bg-navy-50'
: file
? 'border-ok-200 bg-ok-50/50'
: 'border-ink-200 bg-ink-50/60 hover:border-navy-800/40 hover:bg-navy-50/40'
}`}
>
acceptFile(e.target.files?.[0])}
/>
{file ? (
{file.name}
{fmtBytes(file.size)} · PDF
) : (
Arraste o PDF do contrato ou selecione
PDF até 50 MB · texto extraível preferencial
)}
{error && (
{error}
)}
setReq(e.target.value)}
/>
{/* Quota notice */}
{sufficient ? : }
{sufficient ? (
Esta análise consumirá 1 crédito. Saldo atual:
{' '}{usage.credit_balance} → ficará
{' '}{usage.credit_balance - 1}.
) : (
Saldo zerado. Compre créditos antes de enviar uma nova análise.
)}
Idempotency-Key
{idempKey}
{!sufficient ? (
) : (
)}
);
}
// ─── Buy Credits Modal ────────────────────────────────────────────────────────
function BuyCreditsModal({ open, onClose, onPurchase }) {
const [picked, setPicked] = useState('p100');
const [method, setMethod] = useState('pix');
const packages = [
{ id: 'p20', credits: 20, price: 197.00, unit: 9.85, highlight: null, extra: null },
{ id: 'p100', credits: 100, price: 697.00, unit: 6.97, highlight: 'Mais popular', extra: null },
{ id: 'p500', credits: 500, price: 2497.00, unit: 4.99, highlight: null, extra: 'Prioridade na fila' },
];
const methods = [
{ id: 'pix', label: 'PIX', time: 'Instantâneo', tag: 'Recomendado' },
{ id: 'cartao', label: 'Cartão', time: 'Recorrente mensal' },
];
const selected = packages.find((p) => p.id === picked);
return (
{packages.map((p) => {
const active = p.id === picked;
return (
);
})}
Método de pagamento
{methods.map((m) => {
const active = method === m.id;
return (
);
})}
Resumo: {selected.credits} créditos · {method}
{fmtBRL.format(selected.price)}
Nota fiscal eletrônica emitida em até 24h após pagamento.
);
}
// Modal de senha pra PDF protegido: o backend devolveu 422 PDF_PASSWORD_REQUIRED
// e o submit original foi suspenso. Aqui o usuário digita a senha e a UI retenta
// o mesmo upload via onSubmit (que repassa pra submitNewAnalysis(bundle, {pdf_password})).
function PdfPasswordModal({ open, prompt, onClose, onSubmit }) {
const [password, setPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open) { setPassword(''); setSubmitting(false); }
}, [open, prompt]);
if (!open || !prompt) return null;
const submit = (e) => {
e?.preventDefault?.();
if (!password.trim() || submitting) return;
setSubmitting(true);
onSubmit(prompt.bundle, { pdf_password: password });
};
const fileName = prompt.bundle?.file?.name || 'contrato.pdf';
return (
);
}
Object.assign(window, { Dashboard, NewAnalysisModal, BuyCreditsModal, PdfPasswordModal });