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 antigo LAUDO_UI_PLAN.md (que era o plano de redesign, agora concluído). Para histórico da reforma, ver commits 7663ff7, baed461, b4f4b67, e3aa7d6.

Conceitos

ConceitoDescrição
ReportO laudo em si. Vinculado a um Study. Campo content armazena HTML gerado pelo editor Tiptap. Estados: pending → draft → final.
ReportRevisionRegistro 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.
ReportTemplateLayout 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.
ReportPresetModelo de texto pré-preenchido (HTML Tiptap). Recurso do tenant inteirounit_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}/reports e POST /studies/{id}/reports/corrections retornam 403.
  • Super admin: mesmo bloqueio no backend; UI esconde botões.
  • Doutor (user regular, não admin): pode criar rascunho, finalizar, corrigir.
Validação em ReportController::upsertByStudy() e ::correctFinal() — 403 explícito se user.is_super_admin ou user.is_tenant_admin.

Estados do laudo

pending → draft → final

               correction (permanece "final", nova revisão)
  • 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
Laudo finalizado não volta a rascunho. Para alterar, só via correção (que mantém status=final e cria revisão com action=correction).

Audit trail — ReportRevision

Cada ação no laudo gera uma row imutável em report_revisions com o HTML completo antes e depois. Não há soft delete — revisões são forever.

Actions registradas

ActionQuando
draft_createdPrimeiro rascunho criado para um estudo
draft_updatedRascunho sobrescrito com novo conteúdo
finalizedLaudo finalizado (status: draft → final)
correctionCorreção em laudo já finalizado

Fluxo no backend

POST /studies/{id}/reports  com status="draft"
  → Cria/atualiza Report (status=draft)
  → Cria ReportRevision (action=draft_created se primeiro, senão draft_updated)

POST /studies/{id}/reports  com status="final"
  → Requer rascunho existente
  → Atualiza Report (status=final, finalized_at=now())
  → Cria ReportRevision (action=finalized)

POST /studies/{id}/reports/corrections  com content + reason
  → Requer laudo status=final
  → Atualiza Report (content = novo content; status continua final)
  → Cria ReportRevision (action=correction, reason preenchido)

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)

  1. Médico abre estudo → sidebar fecha automaticamente
  2. StudyReportPanel carrega laudo + template da unidade + presets + histórico
  3. Sistema sugere preset automaticamente (matching: study_description_contains > modality > primeiro ativo)
  4. Apenas presets da modalidade do estudo aparecem no select (ou todos se modalidade desconhecida)
  5. Médico seleciona preset (opcional) → texto pré-populado no editor Tiptap
  6. Botão “Pré-visualizar laudo” / “Ver rascunho” / “Ver laudo final” → drawer A4 ao vivo
  7. Edita conteúdo → “Salvar rascunho” → POST /studies/{id}/reports com status=draft
  8. Botão “Finalizar laudo” aparece após rascunho salvo
  9. Finaliza → laudo trava, editor principal fica read-only
  10. Correções via botão “Registrar correção” → editor separado, cria revision com action=correction
  11. Histórico completo disponível no ícone de relógio → modal com accordion
  12. 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):
{
  schema_version: 1,
  regions: {
    header_height: 55,   // mm
    footer_height: 40,   // mm
    body_padding: 20,    // % (convertido pra mm no renderer)
  },
  slots: {
    logo:         { x, y, width },           // % da página
    unit_name:    { x, y, width },           // % da página
    patient_name: { x, y, width },           // % da página
    signature:    { x, y, width, height_percent },
  },
  typography: { font, base_pt, ... },
  assets: {
    background: { data_url, opacity },       // papel timbrado (PNG/JPG base64)
  },
}

Upload de papel timbrado

Fluxo ao fazer upload no editor de template:
  1. Frontend valida file.type (apenas image/png e image/jpeg)
  2. Imagem carregada em <canvas>
  3. Fundo branco preenchido antes do drawImage (evita transparente virar preto no JPEG)
  4. Redimensionada para máximo 1240×1754px (A4 @150dpi)
  5. Exportada como JPEG 75%
  6. Armazenada como base64 no campo assets.background.data_url (JSONB)
Backend valida prefixo via regex /^data:image\/(jpeg|png);base64,/ em TenantSettingsController (logo) e UserController (signature).

Logo da clínica e assinatura do médico

Mesma validação de file.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 via GET /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

max-w-[595px] = 72dpi = 1pt = 1px = 1/2.83mm
Container é 595px (largura de folha A4 a 72dpi). Todos os slots posicionados com 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 do jsPDF.checkPage():
  • Medição: useLayoutEffect roda após cada render e mede todos os elementos block-level (p, h1-h6, li) via getBoundingClientRect() — valores fracionados, sem arredondamento de offsetHeight
  • 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)

┌─ outer clip (bodyH inteiro, overflow:hidden) ──────────┐
│  ↓ cria espaço para header                              │
│  ┌─ inner clip (effectivePadPx top, segmentPx altura) ┐│
│  │  ↓ top: -breaks[N]px (empurra conteúdo pra cima)   ││
│  │  content div (full height, todos os blocos)         ││
│  └────────────────────────────────────────────────────┘│
│  ↓ espaço para footer                                   │
└────────────────────────────────────────────────────────┘
Sem os dois clips aninhados, o conteúdo da página anterior vaza no topo da próxima. A combinação outer+inner resolve sem recorrer a overflow hack nos blocos individuais.

Assinatura e indicador de página

  • Assinatura renderizada em todas as páginas (igual ao PDF).
  • Indicador N/Total no 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 antiga htmlToPlainText() por pipeline completo:
  1. parseHtmlToBlocks(html) — usa DOMParser para converter HTML Tiptap em array de blocos tipados ({ type: 'p' | 'h1' | ... | 'ul' | 'li', spans: [{text, bold, italic, strike}] })
  2. 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 PDF
  • effectivePad = 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 + 5 de 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: 595px na área de conteúdo
Mudar qualquer uma sem mudar as outras desalinha preview e PDF.

2. bodyPad é vertical, nunca lateral

effectivePad = bodyPad × 0.2 × PAGE_H / 100   // vertical em mm
O fator 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 slot logo 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 (commits b4f4b67 + correções posteriores):
  • Editor (Tiptap): usa CSS 595px + cálculo effectiveBodyPx baseado em bodyPad × 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+ com bodyPx - 2×effectivePadPx
  • PDF jsPDF: footerLineY - effectivePad - (headerLineY + effectivePad) para páginas 2+
Se mudar a fórmula de uma, testar as outras duas — diferença de 1 linha é comum e exige ajuste fino.

Arquivos-chave

Backend

ArquivoResponsabilidade
backend/app/Http/Controllers/ReportController.phpCRUD de laudos, audit trail, RBAC (403 para admin), makeVisible(['signature'])
backend/app/Http/Controllers/ReportTemplateController.phpCRUD de templates, myTemplate (resolve template da unidade do user logado)
backend/app/Http/Controllers/ReportPresetController.phpCRUD de presets, is_active via $request->boolean()
backend/app/Http/Controllers/TenantSettingsController.phpLogo do tenant (regex validation)
backend/app/Http/Controllers/UserController.phpSignature (regex validation), has_signature computado no index
backend/app/Models/Report.phpModel com relação a study, author, revisions
backend/app/Models/ReportRevision.phpAudit trail (imutável)
backend/app/Models/ReportTemplate.phpLayout + assets JSONB
backend/app/Models/ReportPreset.phpTexto pré-preenchido + metadata

Frontend

ArquivoResponsabilidade
frontend/components/studies/study-report-panel.tsxPainel principal: editor, rascunho, finalização, correção, histórico, drawer de preview
frontend/components/tenant/report-template-a4-preview.tsxPreview A4 fiel com paginação multi-página (dois clips aninhados)
frontend/components/tenant/tenant-template-editor.tsxEditor de template com sliders em mm, upload de papel timbrado
frontend/components/ui/rich-text-editor.tsxWrapper do Tiptap (bold, italic, strike, listas, headings)
frontend/lib/utils/study-report-pdf.tsRenderer jsPDF do laudo (slots, rich text, multi-page, assinatura em todas)
frontend/lib/utils/tenant-template-pdf.tsRenderer jsPDF do preview de template (no editor de template)
frontend/types/report-template-layout.tsReportTemplateLayoutV1, TemplateAssets

Armadilhas conhecidas

Booleanos em query params (Axios + Laravel)

Axios serializa true como string "true". Regra boolean do Laravel rejeita com 422. Sempre usar $request->boolean('campo') em vez de $request->validate([...'boolean']).
// Correto
if ($request->has('is_active')) {
    $query->where('is_active', $request->boolean('is_active'));
}

Template vs Preset — sem vínculo entre os dois

Preset não tem template_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 os report_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:
docker exec pacs_backend php artisan octane:reload

Pendências menores (não bloqueiam PR)

  • Aviso para séries longas no modal de export (quando instance_count > N e maxFramesPerSeries === 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). Ver MEMBERSHIPS_REFACTOR.md §12.
  • Auto-match de médico solicitante — worker Python indexa tag DICOM RequestingPhysician e faz match por CRM. Ver MEMBERSHIPS_REFACTOR.md §12.5.
  • Papel timbrado e logo em object storage — extrair blobs base64 do PostgreSQL para MinIO/S3. Ver STORAGE_ARCHITECTURE.md Fase 1.