Files
scoreodonto.com/frontend/views/OrthoReports.tsx
T
VPS 4 Builder ba062e92d0 fix(login): render standalone views edge-to-edge e esconde scrollbar do carrossel de onboarding
- App.tsx: wrappers de padding (p-4/md:p-8) e max-w-7xl/mx-auto agora só se aplicam quando não é tela standalone; login/landing/public renderizam full-bleed
- OnboardingChat.tsx: classe ob-noscroll esconde a barra de rolagem do slide intro (mantém scroll), substituindo a custom-scrollbar indefinida no fluxo de login

Inclui também demais alterações pendentes do working tree (snapshot do estado atual de dev).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:23:19 +02:00

411 lines
17 KiB
TypeScript

import React, { useState } from 'react';
import { FileDown, Loader2, Camera, Activity, GraduationCap } from 'lucide-react';
import { TratamentoOrto } from '../types.ts';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { ORTO_SLOTS } from './OrthoPhotoWorkspace.tsx';
// ─── Helpers ──────────────────────────────────────────────────────
// Converte URL de imagem para base64 DataURL para uso no jsPDF
async function imgToDataUrl(url: string): Promise<string | null> {
try {
const res = await fetch(url);
const blob = await res.blob();
return await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
} catch { return null; }
}
// Quebra texto em linhas para jsPDF
function splitText(text: string, maxW: number, charPerW = 2.5): string[] {
if (!text) return [];
const words = text.split(' ');
const maxChars = Math.floor(maxW * charPerW);
const lines: string[] = [];
let line = '';
for (const w of words) {
if ((line + ' ' + w).length > maxChars && line) { lines.push(line); line = w; }
else line = line ? `${line} ${w}` : w;
}
if (line) lines.push(line);
return lines;
}
// ─── PDF 1: Documentação Fotográfica ─────────────────────────────
async function gerarPDFDocumentacao(t: TratamentoOrto) {
const { default: jsPDF } = await import('jspdf');
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const W = 210, H = 297, M = 12;
let y = M;
const accent = [37, 99, 235]; // blue-600
// ── Cabeçalho ──
doc.setFillColor(...accent as [number, number, number]);
doc.rect(0, 0, W, 22, 'F');
doc.setTextColor(255, 255, 255);
doc.setFontSize(13); doc.setFont('helvetica', 'bold');
doc.text('DOCUMENTAÇÃO FOTOGRÁFICA ORTODÔNTICA', M, 9);
doc.setFontSize(8); doc.setFont('helvetica', 'normal');
doc.text('SCOREODONTO.COM', M, 15);
doc.text(`Gerado em ${new Date().toLocaleString('pt-BR')}`, W - M, 15, { align: 'right' });
y = 28;
// ── Dados do paciente ──
doc.setTextColor(30, 30, 30);
doc.setFontSize(11); doc.setFont('helvetica', 'bold');
doc.text(t.pacienteNome.toUpperCase(), M, y); y += 5;
doc.setFontSize(8); doc.setFont('helvetica', 'normal');
doc.setTextColor(100);
const info = [
`Aparelho: ${t.tipoAparelho || '—'}`,
`Início: ${new Date(t.dataInicio).toLocaleDateString('pt-BR')}`,
`Fase: ${t.faseAtual || '—'}`,
`Progresso: ${t.percentualConcluido ?? '—'}%`,
`Status: ${t.status}`,
].join(' | ');
doc.text(info, M, y); y += 4;
doc.setDrawColor(...accent as [number, number, number]);
doc.setLineWidth(0.3);
doc.line(M, y, W - M, y); y += 6;
// ── Fotos em grade ──
const sessoes = await HybridBackend.getOrtoSessoes(t.id);
const allFotos: Record<string, string> = {};
for (const s of (sessoes.sessoes || [])) {
for (const f of (s.fotos || [])) allFotos[f.slot] = f.url;
}
const cats = ['EXTRAORAL', 'INTRAORAL', 'COMPLEMENTAR'];
const COLS = 5;
const cellW = (W - M * 2 - (COLS - 1) * 3) / COLS;
const cellH = cellW * 0.75;
for (const cat of cats) {
const slots = ORTO_SLOTS.filter(s => s.cat === cat);
doc.setFontSize(7); doc.setFont('helvetica', 'bold');
doc.setTextColor(...accent as [number, number, number]);
doc.text(cat, M, y); y += 4;
let col = 0;
let rowY = y;
for (const slot of slots) {
const x = M + col * (cellW + 3);
const url = allFotos[slot.key];
if (url) {
const dataUrl = await imgToDataUrl(url);
if (dataUrl) {
doc.addImage(dataUrl, 'JPEG', x, rowY, cellW, cellH);
}
} else {
doc.setFillColor(240, 240, 240);
doc.roundedRect(x, rowY, cellW, cellH, 1, 1, 'F');
doc.setTextColor(180); doc.setFontSize(5.5);
doc.text('SEM FOTO', x + cellW / 2, rowY + cellH / 2, { align: 'center' });
}
// Label
doc.setFontSize(5); doc.setFont('helvetica', 'normal'); doc.setTextColor(80);
doc.text(slot.label, x + cellW / 2, rowY + cellH + 2.5, { align: 'center' });
col++;
if (col >= COLS) { col = 0; rowY += cellH + 8; }
}
y = rowY + (col > 0 ? cellH + 8 : 0) + 4;
if (y > H - 30) { doc.addPage(); y = M; }
}
// ── Rodapé ──
doc.setTextColor(150); doc.setFontSize(6);
doc.text(`SCOREODONTO.COM · ${t.pacienteNome} · Documentação Fotográfica`, W / 2, H - 6, { align: 'center' });
doc.save(`FOTO_${t.pacienteNome.replace(/\s+/g, '_')}_${new Date().toISOString().slice(0, 10)}.pdf`);
}
// ─── PDF 2: Evolução Ortodôntica ──────────────────────────────────
async function gerarPDFEvolucao(t: TratamentoOrto) {
const { default: jsPDF } = await import('jspdf');
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const W = 210, H = 297, M = 14;
let y = M;
const accent = [5, 150, 105]; // emerald-600
doc.setFillColor(...accent as [number, number, number]);
doc.rect(0, 0, W, 22, 'F');
doc.setTextColor(255); doc.setFontSize(13); doc.setFont('helvetica', 'bold');
doc.text('RELATÓRIO DE EVOLUÇÃO ORTODÔNTICA', M, 9);
doc.setFontSize(8); doc.setFont('helvetica', 'normal');
doc.text('SCOREODONTO.COM', M, 15);
doc.text(`Gerado em ${new Date().toLocaleString('pt-BR')}`, W - M, 15, { align: 'right' });
y = 28;
doc.setTextColor(30); doc.setFontSize(12); doc.setFont('helvetica', 'bold');
doc.text(t.pacienteNome, M, y); y += 5;
doc.setFontSize(8); doc.setFont('helvetica', 'normal'); doc.setTextColor(90);
doc.text(`${t.tipoAparelho || '—'} · Início: ${new Date(t.dataInicio).toLocaleDateString('pt-BR')} · ${t.percentualConcluido ?? '—'}% concluído · ${t.evolucoes.length} atendimento(s)`, M, y); y += 5;
// Stats boxes
const stats = [
{ l: 'ATENDIMENTOS', v: String(t.evolucoes.length) },
{ l: 'FASE ATUAL', v: t.faseAtual || '—' },
{ l: 'FIO ATUAL', v: t.fioAtual || '—' },
{ l: 'MESES REST.', v: t.mesesRestantes ? `${t.mesesRestantes}m` : '—' },
];
const bw = (W - M * 2 - 9) / 4;
stats.forEach((s, i) => {
const bx = M + i * (bw + 3);
doc.setFillColor(240, 253, 244);
doc.roundedRect(bx, y, bw, 12, 1, 1, 'F');
doc.setFontSize(9); doc.setFont('helvetica', 'bold'); doc.setTextColor(...accent as [number, number, number]);
doc.text(s.v, bx + bw / 2, y + 6, { align: 'center' });
doc.setFontSize(5.5); doc.setFont('helvetica', 'normal'); doc.setTextColor(120);
doc.text(s.l, bx + bw / 2, y + 10.5, { align: 'center' });
});
y += 17;
doc.setDrawColor(...accent as [number, number, number]); doc.setLineWidth(0.3);
doc.line(M, y, W - M, y); y += 5;
// Timeline
const sorted = [...t.evolucoes].sort((a, b) => new Date(a.data).getTime() - new Date(b.data).getTime());
for (const [i, ev] of sorted.entries()) {
if (y > H - 25) { doc.addPage(); y = M; }
// Número + data
doc.setFillColor(...accent as [number, number, number]);
doc.circle(M + 2.5, y + 2.5, 2.5, 'F');
doc.setTextColor(255); doc.setFontSize(5.5); doc.setFont('helvetica', 'bold');
doc.text(String(i + 1), M + 2.5, y + 3.2, { align: 'center' });
const ex = M + 8;
doc.setTextColor(30); doc.setFontSize(8); doc.setFont('helvetica', 'bold');
doc.text(`${new Date(ev.data).toLocaleDateString('pt-BR')}${ev.tipo}`, ex, y + 3);
y += 5;
doc.setFontSize(7); doc.setFont('helvetica', 'normal'); doc.setTextColor(60);
doc.text(ev.procedimento, ex, y + 2);
y += 5;
const tags: string[] = [];
if (ev.fio) tags.push(`Fio: ${ev.fio}`);
if (ev.elastico) tags.push(`Elástico: ${ev.elastico}`);
if (ev.higiene != null) tags.push(`Higiene: ${ev.higiene}/10`);
if (ev.cooperacao) tags.push(`Cooperação: ${ev.cooperacao}`);
if (ev.procedimentos?.length) tags.push(...ev.procedimentos);
if (tags.length) {
doc.setFontSize(6.5); doc.setTextColor(80);
doc.text(tags.join(' · '), ex, y + 2);
y += 4;
}
if (ev.observacoes) {
doc.setFontSize(6); doc.setTextColor(120); doc.setFont('helvetica', 'italic');
doc.text(`Obs: ${ev.observacoes}`, ex, y + 2);
doc.setFont('helvetica', 'normal');
y += 4;
}
doc.setTextColor(180); doc.setFontSize(6);
doc.text(`Próx. retorno: ${new Date(ev.proximoRetorno).toLocaleDateString('pt-BR')}`, ex, y + 2);
y += 6;
if (i < sorted.length - 1) {
doc.setDrawColor(220); doc.setLineWidth(0.1);
doc.line(M, y, W - M, y);
y += 4;
}
}
doc.setTextColor(150); doc.setFontSize(6);
doc.text(`SCOREODONTO.COM · ${t.pacienteNome} · Evolução Ortodôntica`, W / 2, H - 6, { align: 'center' });
doc.save(`EVOLUCAO_${t.pacienteNome.replace(/\s+/g, '_')}_${new Date().toISOString().slice(0, 10)}.pdf`);
}
// ─── PDF 3: Caso Clínico para Tutoria ────────────────────────────
async function gerarPDFCasoClinico(t: TratamentoOrto) {
const { default: jsPDF } = await import('jspdf');
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const W = 210, H = 297, M = 14;
let y = M;
const accent = [99, 102, 241]; // indigo-500
doc.setFillColor(...accent as [number, number, number]);
doc.rect(0, 0, W, 22, 'F');
doc.setTextColor(255); doc.setFontSize(13); doc.setFont('helvetica', 'bold');
doc.text('CASO CLÍNICO PARA TUTORIA ORTODÔNTICA', M, 9);
doc.setFontSize(8); doc.setFont('helvetica', 'normal');
doc.text('SCOREODONTO.COM · CONFIDENCIAL', M, 15);
doc.text(`Gerado em ${new Date().toLocaleString('pt-BR')}`, W - M, 15, { align: 'right' });
y = 28;
const field = (label: string, value: string | undefined | null) => {
if (!value) return;
doc.setFontSize(7); doc.setFont('helvetica', 'bold'); doc.setTextColor(120);
doc.text(label.toUpperCase(), M, y); y += 3.5;
doc.setFontSize(8); doc.setFont('helvetica', 'normal'); doc.setTextColor(30);
const lines = splitText(value, W - M * 2);
lines.forEach(l => { doc.text(l, M, y); y += 4; });
y += 1;
};
const section = (title: string) => {
if (y > H - 30) { doc.addPage(); y = M; }
doc.setFillColor(...accent as [number, number, number]);
doc.rect(M, y, W - M * 2, 7, 'F');
doc.setTextColor(255); doc.setFontSize(8); doc.setFont('helvetica', 'bold');
doc.text(title, M + 3, y + 4.5);
doc.setTextColor(30);
y += 10;
};
// ── Paciente ──
section('IDENTIFICAÇÃO DO CASO');
field('Paciente', t.pacienteNome);
field('Responsável', t.responsavel);
field('Aparelho', t.tipoAparelho);
field('Data de Início', new Date(t.dataInicio).toLocaleDateString('pt-BR'));
field('Status', t.status);
field('Fase Atual', t.faseAtual);
field('Fio Atual', t.fioAtual);
field('Progresso', t.percentualConcluido != null ? `${t.percentualConcluido}%` : undefined);
// ── Triagem ──
if (t.triagem) {
section('TRIAGEM CLÍNICA');
field('Queixa Principal', t.triagem.queixaPrincipal);
field('Histórico Médico', t.triagem.historicoMedico);
field('Medicação', t.triagem.medicacao);
field('Alergias', t.triagem.alergias);
field('Hábitos', t.triagem.habitos);
if (t.triagem.respiracaoBucal != null)
field('Respiração Bucal', t.triagem.respiracaoBucal ? 'SIM' : 'NÃO');
}
// ── Diagnóstico ──
if (t.diagnostico) {
section('DIAGNÓSTICO ORTODÔNTICO');
field('Classe de Angle', t.diagnostico.classeAngle);
field('Perfil', t.diagnostico.perfil);
field('Apinhamento', t.diagnostico.apinhamento);
field('Mordida', t.diagnostico.mordida);
field('Linha Média', t.diagnostico.linhaMedia);
}
// ── Plano de tratamento ──
section('PLANO DE TRATAMENTO');
field('Extrações', t.extracoes);
field('Elásticos', t.elasticos);
field('IPR Planejado', t.iprPlanejado);
field('Tempo Estimado', t.tempoEstimado);
field('Sequência de Fios', t.sequenciaFios);
// ── Evoluções (últimas 5) ──
section('EVOLUÇÕES RECENTES');
const ultimas = [...t.evolucoes].sort((a, b) => new Date(b.data).getTime() - new Date(a.data).getTime()).slice(0, 5);
for (const ev of ultimas) {
if (y > H - 30) { doc.addPage(); y = M; }
doc.setFontSize(7.5); doc.setFont('helvetica', 'bold'); doc.setTextColor(30);
doc.text(`${new Date(ev.data).toLocaleDateString('pt-BR')}${ev.tipo}`, M, y); y += 4;
doc.setFontSize(7); doc.setFont('helvetica', 'normal'); doc.setTextColor(60);
doc.text(ev.procedimento, M + 3, y); y += 4;
if (ev.fio || ev.cooperacao || ev.higiene != null) {
const tags = [ev.fio && `Fio: ${ev.fio}`, ev.cooperacao && `Coop: ${ev.cooperacao}`, ev.higiene != null && `Higiene: ${ev.higiene}/10`].filter(Boolean).join(' | ');
doc.setTextColor(120); doc.setFontSize(6.5);
doc.text(tags, M + 3, y); y += 5;
}
y += 1;
}
// ── Fotos ──
const sessoes = await HybridBackend.getOrtoSessoes(t.id);
const fotosIniciais = sessoes.sessoes?.find((s: any) => s.tipo === 'inicial')?.fotos || sessoes.sessoes?.[0]?.fotos || [];
if (fotosIniciais.length > 0) {
section('DOCUMENTAÇÃO FOTOGRÁFICA');
const COLS = 4;
const cellW = (W - M * 2 - (COLS - 1) * 3) / COLS;
const cellH = cellW * 0.75;
let col = 0, rowY = y;
for (const f of fotosIniciais.slice(0, 12)) {
if (rowY + cellH > H - 20) { doc.addPage(); rowY = M; col = 0; }
const x = M + col * (cellW + 3);
const dataUrl = await imgToDataUrl(f.url);
if (dataUrl) doc.addImage(dataUrl, 'JPEG', x, rowY, cellW, cellH);
col++;
if (col >= COLS) { col = 0; rowY += cellH + 3; }
}
y = rowY + (col > 0 ? cellH + 3 : 0) + 5;
}
doc.setTextColor(150); doc.setFontSize(6);
doc.text(`SCOREODONTO.COM · CASO CLÍNICO CONFIDENCIAL · ${t.pacienteNome}`, W / 2, H - 6, { align: 'center' });
doc.save(`CASO_${t.pacienteNome.replace(/\s+/g, '_')}_${new Date().toISOString().slice(0, 10)}.pdf`);
}
// ─── Componente de botões ─────────────────────────────────────────
interface Props { t: TratamentoOrto; }
export const OrthoReports: React.FC<Props> = ({ t }) => {
const toast = useToast();
const [loading, setLoading] = useState<string | null>(null);
const run = async (tipo: string, fn: () => Promise<void>) => {
setLoading(tipo);
try { await fn(); toast.success('PDF GERADO E BAIXADO!'); }
catch (err) { console.error(err); toast.error('ERRO AO GERAR PDF.'); }
finally { setLoading(null); }
};
const btns = [
{
id: 'foto', icon: <Camera size={15} />, label: 'DOCUMENTAÇÃO FOTOGRÁFICA',
desc: 'Grade completa dos 15 slots com fotos',
color: 'bg-indigo-600 hover:bg-indigo-700',
fn: () => gerarPDFDocumentacao(t),
},
{
id: 'evolucao', icon: <Activity size={15} />, label: 'EVOLUÇÃO ORTODÔNTICA',
desc: 'Timeline cronológico de todos os atendimentos',
color: 'bg-emerald-600 hover:bg-emerald-700',
fn: () => gerarPDFEvolucao(t),
},
{
id: 'caso', icon: <GraduationCap size={15} />, label: 'CASO CLÍNICO (TUTORIA)',
desc: 'Triagem + diagnóstico + plano + evoluções + fotos',
color: 'bg-violet-600 hover:bg-violet-700',
fn: () => gerarPDFCasoClinico(t),
},
];
return (
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
<div className="px-5 py-4 border-b border-gray-100 bg-gray-50 flex items-center gap-2">
<FileDown size={16} className="text-teal-500" />
<h3 className="font-black text-gray-700 uppercase text-sm">RELATÓRIOS E PDFs</h3>
</div>
<div className="p-5 grid grid-cols-1 sm:grid-cols-3 gap-3">
{btns.map(b => (
<button key={b.id} onClick={() => run(b.id, b.fn)} disabled={!!loading}
className={`flex flex-col items-center gap-2 p-4 rounded-xl text-white transition-all disabled:opacity-50 ${b.color} shadow-sm`}>
{loading === b.id
? <Loader2 size={20} className="animate-spin" />
: b.icon}
<span className="text-[10px] font-black uppercase leading-tight text-center">{b.label}</span>
<span className="text-[9px] font-medium opacity-70 text-center leading-tight">{b.desc}</span>
</button>
))}
</div>
<p className="px-5 pb-4 text-[10px] text-gray-400 font-bold uppercase text-center">
PDFs GERADOS LOCALMENTE NENHUM DADO É ENVIADO PARA SERVIDORES EXTERNOS
</p>
</div>
);
};