Sistema de Laudos
Descrição do estado atual (v1) do sistema de laudos — como funciona, onde mora cada peça, e quais invariantes não podem ser violadas ao mexer no código.Este documento substitui o antigoLAUDO_UI_PLAN.md(que era o plano de redesign, agora concluído). Para histórico da reforma, ver commits7663ff7,baed461,b4f4b67,e3aa7d6.
Conceitos
| Conceito | Descrição |
|---|---|
| Report | O laudo em si. Vinculado a um Study. Campo content armazena HTML gerado pelo editor Tiptap. Estados: pending → draft → final. |
| ReportRevision | Registro imutável de auditoria. Cada ação no laudo (criar rascunho, salvar, finalizar, corrigir) gera uma revisão com previous_content e new_content completos. |
| ReportTemplate | Layout visual do PDF (header/footer, slots de logo/assinatura/paciente, tipografia, papel timbrado). Vinculado a uma Unit. Uma unit tem no máximo um template ativo — ao ativar outro, os demais são desativados automaticamente. |
| ReportPreset | Modelo de texto pré-preenchido (HTML Tiptap). Recurso do tenant inteiro — unit_id sempre null. Filtrado pelo frontend pela modalidade do estudo. Sugestão automática por study_description_contains ou modality. |
RBAC — quem pode laudar
Apenas usuários regulares (doutores) podem criar e editar laudos.- Tenant admin: pode visualizar laudos;
POST /studies/{id}/reportsePOST /studies/{id}/reports/correctionsretornam 403. - Super admin: mesmo bloqueio no backend; UI esconde botões.
- Doutor (user regular, não admin): pode criar rascunho, finalizar, corrigir.
ReportController::upsertByStudy() e ::correctFinal() — 403 explícito se user.is_super_admin ou user.is_tenant_admin.
Estados do laudo
- pending — estudo sem laudo iniciado (implícito, não é row no banco)
- draft — rascunho salvo, editor principal aberto
- final — finalizado, editor principal trava, só correções via editor separado
status=final e cria revisão com action=correction).
Audit trail — ReportRevision
Cada ação no laudo gera uma row imutável emreport_revisions com o HTML completo antes e depois. Não há soft delete — revisões são forever.
Actions registradas
| Action | Quando |
|---|---|
draft_created | Primeiro rascunho criado para um estudo |
draft_updated | Rascunho sobrescrito com novo conteúdo |
finalized | Laudo finalizado (status: draft → final) |
correction | Correção em laudo já finalizado |
Fluxo no backend
Endpoint de histórico
GET /studies/{id}/reports/revisions — retorna array ordenado cronologicamente. UI renderiza em accordion na modal de histórico, com HTML formatado (não texto puro).
Fluxo completo de uso (médico)
- Médico abre estudo → sidebar fecha automaticamente
StudyReportPanelcarrega laudo + template da unidade + presets + histórico- Sistema sugere preset automaticamente (matching:
study_description_contains>modality> primeiro ativo) - Apenas presets da modalidade do estudo aparecem no select (ou todos se modalidade desconhecida)
- Médico seleciona preset (opcional) → texto pré-populado no editor Tiptap
- Botão “Pré-visualizar laudo” / “Ver rascunho” / “Ver laudo final” → drawer A4 ao vivo
- Edita conteúdo → “Salvar rascunho” →
POST /studies/{id}/reportscomstatus=draft - Botão “Finalizar laudo” aparece após rascunho salvo
- Finaliza → laudo trava, editor principal fica read-only
- Correções via botão “Registrar correção” → editor separado, cria revision com
action=correction - Histórico completo disponível no ícone de relógio → modal com accordion
- Export PDF apenas após finalização — modal de seleção de imagens (screenshots DICOM) → PDF gerado no frontend com jsPDF
Template — layout visual do PDF
Estrutura
ReportTemplateLayoutV1 (em frontend/types/report-template-layout.ts):
Upload de papel timbrado
Fluxo ao fazer upload no editor de template:- Frontend valida
file.type(apenasimage/pngeimage/jpeg) - Imagem carregada em
<canvas> - Fundo branco preenchido antes do
drawImage(evita transparente virar preto no JPEG) - Redimensionada para máximo 1240×1754px (A4 @150dpi)
- Exportada como JPEG 75%
- Armazenada como base64 no campo
assets.background.data_url(JSONB)
/^data:image\/(jpeg|png);base64,/ em TenantSettingsController (logo) e UserController (signature).
Logo da clínica e assinatura do médico
Mesma validação defile.type, mas estratégia de formato diferente:
- PNG de entrada → saída PNG (preserva transparência — essencial para logos e assinaturas)
- JPEG de entrada → fundo branco + JPEG
signature em $hidden do model User (evita expor base64 pesado em todas as listagens). Exposta seletivamente via $report->author->makeVisible(['signature']) nos endpoints de leitura/escrita do laudo em ReportController.
has_signature: bool computado e retornado em GET /admin/users — permite UI mostrar ícone sem carregar base64.
Resolução automática de template
O médico não escolhe template manualmente. Backend resolve viaGET /tenant/my-template — retorna o template ativo cuja unit_id bate com a unidade do usuário logado. Aplicado automaticamente no PDF e no drawer de preview.
Preview A4 fiel — preview-web
Componente:frontend/components/tenant/report-template-a4-preview.tsx
Proporcionalidade
left/top em % da página inteira (não relativo à região) — exatamente como no PDF renderizado. Resultado: preview web e PDF são visualmente idênticos.
Paginação multi-página
O componente pagina o conteúdo dinamicamente, espelhando o comportamento dojsPDF.checkPage():
- Medição:
useLayoutEffectroda após cada render e mede todos os elementos block-level (p,h1-h6,li) viagetBoundingClientRect()— valores fracionados, sem arredondamento deoffsetHeight - Cálculo de quebras: se
block.bottom > pageEnd, o bloco inteiro vai para a próxima página. Nenhum bloco é cortado no meio. - Página 1:
pageEnd = effectiveBodyPx(inclui título + margin) - Páginas 2+:
pageEnd = top + (bodyPx - 2×effectivePadPx)— abate o top padding da página e reserva espaço do rodapé
Arquitetura de clipping — dois clips aninhados (páginas N > 0)
Assinatura e indicador de página
- Assinatura renderizada em todas as páginas (igual ao PDF).
- Indicador
N/Totalno rodapé quando multi-página.
PDF — renderizador jsPDF
Arquivo:frontend/lib/utils/study-report-pdf.ts
Proporcionalidade
Mesma constante mágica do preview:PAGE_W = 210mm, PAGE_H = 297mm, 1pt = 1px = 1/2.83mm. Slots sempre em % da página inteira (não relativo à região).
Rich text preservado
Substituição da antigahtmlToPlainText() por pipeline completo:
parseHtmlToBlocks(html)— usa DOMParser para converter HTML Tiptap em array de blocos tipados ({ type: 'p' | 'h1' | ... | 'ul' | 'li', spans: [{text, bold, italic, strike}] })renderHtmlBlocks(doc, blocks, ctx)— para cada bloco:- Troca fonte/tamanho por span (bold, italic, strike)
- Word-wrap por token (
doc.splitTextToSize) - Strikethrough manual (linha desenhada por cima)
- Headings escalados (h1 > h2 > h3)
- Quebra de página automática via
ctx.checkPage(needed)
reportBody em StudyReportPdfOptions recebe HTML do Tiptap, não texto puro.
Layout
BODY_X = PAGE_MARGIN— sem bodyPad lateral no PDFeffectivePad = bodyPad × 0.2 × PAGE_H / 100— padding vertical em mm (idem ao preview)- Título “Laudo Médico” escrito no body com margin bottom; conteúdo começa logo abaixo
- Assinatura em todas as páginas, centralizada com
sy + 5de espaçamento
Invariantes críticas (NÃO ALTERAR sem calibrar os três)
Editor Tiptap, preview A4 e PDF jsPDF têm três algoritmos de paginação independentes que precisam coincidir exatamente. Qualquer mudança em fórmulas de padding, quebra ou proporcionalidade exige re-calibrar os três juntos.1. Proporcionalidade 595px = 72dpi
- Preview:
max-w-[595px](Tailwind) - PDF: constantes
PAGE_W = 210mm,PAGE_H = 297mm - Editor Tiptap: CSS
width: 595pxna área de conteúdo
2. bodyPad é vertical, nunca lateral
0.2 converte bodyPad (input em % da página) pra proporção razoável de padding vertical. Aplicado no PDF e no preview, mas nunca como padding horizontal. BODY_X = PAGE_MARGIN é fixo (sem bodyPad lateral).
3. Slots sempre em % da página inteira
Nunca relativo à região (header/body/footer). Um slotlogo com x=7, y=5, width=25 significa 7% da PAGE_W, 5% da PAGE_H, 25% da PAGE_W. O mesmo cálculo no preview web e no PDF.
4. Dois clips no preview (páginas 2+)
Outer clip =bodyH inteiro. Inner clip = effectivePadPx de top + segmentPx de altura. Sem essa aninhação, o conteúdo da página anterior vaza no topo da próxima.
5. getBoundingClientRect() no lugar de offsetTop
Valores fracionados evitam que arredondamento faça um bloco “parecer” que cabe quando visualmente ultrapassa a divisória. offsetTop arredonda; getBoundingClientRect().bottom não.
6. Calibração três-vias do indicador de quebra
O indicador visual de quebra de página no editor Tiptap (linha divisória) precisa coincidir com onde o preview quebra E onde o PDF quebra. Calibração atual (commitsb4f4b67 + correções posteriores):
- Editor (Tiptap): usa CSS 595px + cálculo
effectiveBodyPxbaseado embodyPad × 0.2%do PAGE_W (não PAGE_H — correção específica pra bater com o rendering CSS) - Preview A4: usa DOM real via
getBoundingClientRect(), páginas 2+ combodyPx - 2×effectivePadPx - PDF jsPDF:
footerLineY - effectivePad - (headerLineY + effectivePad)para páginas 2+
Arquivos-chave
Backend
| Arquivo | Responsabilidade |
|---|---|
backend/app/Http/Controllers/ReportController.php | CRUD de laudos, audit trail, RBAC (403 para admin), makeVisible(['signature']) |
backend/app/Http/Controllers/ReportTemplateController.php | CRUD de templates, myTemplate (resolve template da unidade do user logado) |
backend/app/Http/Controllers/ReportPresetController.php | CRUD de presets, is_active via $request->boolean() |
backend/app/Http/Controllers/TenantSettingsController.php | Logo do tenant (regex validation) |
backend/app/Http/Controllers/UserController.php | Signature (regex validation), has_signature computado no index |
backend/app/Models/Report.php | Model com relação a study, author, revisions |
backend/app/Models/ReportRevision.php | Audit trail (imutável) |
backend/app/Models/ReportTemplate.php | Layout + assets JSONB |
backend/app/Models/ReportPreset.php | Texto pré-preenchido + metadata |
Frontend
| Arquivo | Responsabilidade |
|---|---|
frontend/components/studies/study-report-panel.tsx | Painel principal: editor, rascunho, finalização, correção, histórico, drawer de preview |
frontend/components/tenant/report-template-a4-preview.tsx | Preview A4 fiel com paginação multi-página (dois clips aninhados) |
frontend/components/tenant/tenant-template-editor.tsx | Editor de template com sliders em mm, upload de papel timbrado |
frontend/components/ui/rich-text-editor.tsx | Wrapper do Tiptap (bold, italic, strike, listas, headings) |
frontend/lib/utils/study-report-pdf.ts | Renderer jsPDF do laudo (slots, rich text, multi-page, assinatura em todas) |
frontend/lib/utils/tenant-template-pdf.ts | Renderer jsPDF do preview de template (no editor de template) |
frontend/types/report-template-layout.ts | ReportTemplateLayoutV1, TemplateAssets |
Armadilhas conhecidas
Booleanos em query params (Axios + Laravel)
Axios serializatrue como string "true". Regra boolean do Laravel rejeita com 422. Sempre usar $request->boolean('campo') em vez de $request->validate([...'boolean']).
Template vs Preset — sem vínculo entre os dois
Preset não temtemplate_id. O template é resolvido automaticamente pela unidade do médico (GET /tenant/my-template). O preset é selecionado pelo médico e fornece o texto inicial. Os dois são aplicados separadamente no PDF.
Preset é do tenant, não da unit
Todos osreport_presets.unit_id são null. Qualquer médico de qualquer unidade do mesmo tenant pode usar qualquer preset. validatePresetIdForStudy() valida por tenant_id apenas (isolamento já garantido pelo trait BelongsToTenant).
Signature em $hidden
User.signature está em $hidden para não expor base64 pesado em todas as listagens. ReportController chama $report->author->makeVisible(['signature']) nos 3 endpoints de laudo. AdminUserList retorna has_signature: bool computado em vez do campo.
Logo no painel de laudo
GET /tenant/settings foi movido do grupo tenant_admin para leitura geral autenticada — radiologistas precisam da logo para o preview e PDF.
Reload obrigatório do Octane após mudanças PHP
Octane mantém o processo vivo. Mudanças em.php não são detectadas:
Pendências menores (não bloqueiam PR)
- Aviso para séries longas no modal de export (quando
instance_count > NemaxFramesPerSeries === 0) — UX - Reprocessar thumbnails de séries existentes — comando utilitário futuro
Mudanças previstas em v1.x / v2
Esses itens dependem de refactors maiores documentados separadamente:- Role
referring_physician— médico solicitante externo com UI read-only (só vê estudos que pediu). VerMEMBERSHIPS_REFACTOR.md§12. - Auto-match de médico solicitante — worker Python indexa tag DICOM
RequestingPhysiciane faz match por CRM. VerMEMBERSHIPS_REFACTOR.md§12.5. - Papel timbrado e logo em object storage — extrair blobs base64 do PostgreSQL para MinIO/S3. Ver
STORAGE_ARCHITECTURE.mdFase 1.
