52 Commits

Author SHA1 Message Date
Leonardo b0b636c660 log: sessao 22/05 - Melissa UX overhaul + 5 saas-docs (Fases 2-5)
Sessao completa de ~14 commits. 2 grandes blocos:

BLOCO 1 — Melissa UI overhaul: tray bottom-right (substitui topbar
band), mobile collapse parcial em <md, busca global unificada
(MelissaBusca ganha "Ir para [data]", popover da agenda deletado),
dock com 4 builtins, hero resumo com cancelado/remarcado, settings+
ajuda click-outside, cronometro evento-aware (botao ⏱ na timeline +
sessionPlan + confirm fechar), documents edit in-place via
document_generated.documento_id, wire-up dos 5 botoes do preview.

BLOCO 2 — 5 docs saas novas (03-07 em development/saas-docs/) +
SQL imports + 60 FAQs total. Cobertura: aba Documentos paciente,
pagina Templates, Assinatura eletronica, Emissao de recibo
profissional, Relatorios + 3 formatos de export.

Memorias adicionadas:
- feedback_tailwind_utility_load_order (hidden perde pra CSS base
  do componente por ordem de carga Vite)
- project_documents_reedit_in_place (linkage documento_id + editingDocId)

PROXIMA SESSAO (23/05): Fase 6 restante (C12 antecipar UX iter —
unico item de codigo da lista de ontem), Fase 7 restante (regressao
Agenda C7-C13, validacao manual). Antes/depois: panorama MVP no
ROADMAP canonico — ainda restam #12 papel timbrado, #15 NFS-e,
§1.5 Sentry, Asaas Fase B, M4 cutover, validacao centralizada
de forms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 20:37:10 -03:00
Leonardo 701d9f4fcc saas-docs: doc Relatorios e exportacao (Fase 5 #13)
Doc 07 cobrindo a pagina de Relatorios + 3 formatos de exportacao:
- Layout 2-col (sidebar stats+filtros, main grafico+tabela)
- 4 periodos (semana/mes/3m/6m), agrupamento auto (dia vs week ISO
  vs month ISO)
- 5 KPIs clicaveis como filtros (total/realizadas/faltas/canc/remarc)
- Grafico Chart.js com cores por status
- DataTable paginada + status com tag colorida
- Export PDF (HTML->PDF A4, KPIs + tabela)
- Export Excel XLSX (exceljs dinamico, frozen header, alternating
  rows, branded, formatos data+currency)
- Export CSV (vanilla, BOM UTF-8, separador ; pt-BR)
- Filtros aplicados na tela respeitados na exportacao
- Nome do arquivo com timestamp pra evitar overwrite
- Notas dev: reportExport.service.js, pdf.service, exceljs lazy load

12 FAQs: como ver, periodos disponiveis, exportar PDF/Excel/CSV,
quando usar qual formato, filtros respeitados, formulas, agrupamento
do grafico, filtrar por paciente, ver outro terapeuta, nome do
arquivo, exportacao agendada (pendente).

categoria='Relatórios', pagina_path='/melissa/relatorios', ordem=7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:59:12 -03:00
Leonardo 34412c6883 saas-docs: doc da Emissao de recibo profissional (Fase 4 #14)
Doc 06 cobrindo o quick path de emissao de recibo:
- Quando o botao aparece (AgendaEventoFinanceiroPanel com record
  status=paid)
- O que vem auto-preenchido (paciente, sessao, valor, forma pgto,
  terapeuta+registro formatado, clinica+CNPJ, data)
- Registro profissional generico — CRP/CRM/CRFa/CREFITO/CRESS/CRN/
  Outro (variavel terapeuta_registro auto-formata)
- Valor por extenso (helper valorExtenso.js, ate 999 milhoes)
- Onde fica salvo (download + aba Documentos categoria 'Recibo')
- Quick path emitirReciboParaSessao() vs flow manual de Gerar
- Notas dev: service, helper, mapping, migration do template,
  localizacao do botao

12 FAQs cobrindo casos comuns: emitir recibo de sessao paga, por
que botao nao aparece, valor por extenso correto, suporte multi-
conselho, onde salva, recibo avulso, CRP vazio, CNPJ formatado,
corrigir valor, enviar pra assinar, data sessao vs emissao,
reemitir.

categoria='Financeiro', pagina_path='/melissa/agenda', ordem=6.
SQL import em database-novo/tmp/import-doc-recibo-profissional.sql.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:31:55 -03:00
Leonardo 3a42b0696d saas-docs: doc da Assinatura eletronica de documentos (Fase 3 #7)
Doc 05 cobrindo o fluxo end-to-end de assinatura eletronica:
- Visao geral (terapeuta cria solicitacao -> link publico -> paciente
  abre sem login -> aceite LGPD -> assinatura registrada server-side)
- Lado terapeuta: DocumentSignatureDialog (signatarios, toggle link
  publico, validade 24h/3d/7d/30d, URL copyavel)
- Lado paciente publico: SharedDocumentPage com /shared/document/:token
  (preview + painel LGPD + checkbox aceite + assinar/recusar + SHA-256
  computado client-side)
- Audit trail: hash + timestamp server + IP/UA via inet_client_addr()
  e current_setting (anti-spoof)
- Portal logado: PortalDocumentos lista pendencias com KPIs + filtro
- Expiracao de link, multiplos signatarios, validade legal LGPD/CFP/
  MP2200-2 (limite ICP-Brasil)
- Notas dev: RPCs, service, composable, components, pendencia notificacao
  automatica via Modulo 6

12 FAQs cobrindo: como pedir assinatura, paciente sem login, audit
registrado, terapeuta assinar tambem, validade do link, recusar, portal
do paciente, envio manual hoje, validade legal, integridade do conteudo,
cancelar solicitacao, multiplos signatarios.

categoria='Documentos', pagina_path='/melissa/paciente', ordem=5.
SQL import em database-novo/tmp/import-doc-assinatura-eletronica.sql.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:26:32 -03:00
Leonardo 7dd8cde8b4 saas-docs: doc da pagina de Templates de documentos (Fase 2)
Doc 04 cobrindo a pagina de gestao de templates:
- Globais (sistema, read-only) vs Tenant (seus, editaveis)
- Lista em grid com cards + badge "padrao" pros globais
- Preview de template global (iframe sandbox A4) + botao Duplicar
- Criar novo template (nome/tipo/desc/cabecalho/corpo/rodape)
- Editor rich-text com menu de variaveis (insere {{nome_var}})
- Lista de variaveis disponiveis (paciente/terapeuta/clinica/sessao/geral)
- Mobile drawer pros templates
- Duplicar (cria copia em "Seus templates" com sufixo "(copia)")
- Desativar (soft-delete, docs antigos continuam acessiveis)
- Mapeamento tipo template -> categoria do doc gerado

12 FAQs: pra que serve, por que nao edita padroes, como usar variavel,
quais variaveis, recuperar desativado, duplicar pra personalizar,
global vs tenant, imagens (logo/assinatura), cabecalho/rodape em todas
as paginas, variaveis obrigatorias, limites, compartilhamento entre
terapeutas do mesmo tenant.

categoria='Documentos', pagina_path='/melissa/documentos-templates',
ordem=4. SQL import em database-novo/tmp/import-doc-documentos-
templates.sql.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:04:17 -03:00
Leonardo ec56f9429b saas-docs: doc da aba Documentos do paciente (Fase 2)
Doc 03 cobrindo o fluxo completo da aba Documentos no prontuario:
- Layout 2-col (sidebar de tipos + main grid)
- Toolbar: Atualizar/Gerar/Upload
- Upload (drag-drop, metadados, visibilidade)
- 11 tipos de documento + classificacao automatica
- Gerar a partir de template (fluxo 3 steps com auto-fill de vars)
- Edicao in-place (re-editar doc gerado, preserva ID+audit) — feature
  nova de 22/05
- Preview com 5 acoes (Baixar/Editar/Compartilhar/Assinar/Excluir)
- Share dialog (link publico temporario)
- Assinatura eletronica
- Soft-delete + retencao 5 anos (LGPD/CFP)
- Mobile drawer pros tipos

12 FAQs cobrindo casos comuns: upload, geracao, auto-fill, edicao
de gerado, edicao de uploaded, share, sign, recuperar excluido,
formatos aceitos, visibilidade, fix do bug 22/05 dos botoes do preview.

categoria='Documentos', pagina_path='/melissa/paciente', ordem=3.
SQL import em database-novo/tmp/import-doc-documentos-paciente.sql.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:04:05 -03:00
Leonardo 89bf181742 melissa/paciente-docs: wire-up preview actions + Editar abre dialog em modo edicao
DocumentPreviewDialog emitia @download/@edit/@share/@sign/@delete que
o MelissaPatientDocuments nao ouvia — os 5 botoes da sidebar do preview
caiam no vazio. Adicionado wire-up roteando pros mesmos handlers do
card (onDownload, onEdit, onShare, onSign, onDelete). Share/sign/delete
fecham o preview antes de abrir o proprio dialog pra UX limpa; download
mantem preview aberto (acao instantanea).

DocumentGenerateDialog ganha prop editing-doc-id. Quando setado:
- Busca template_id + dados_preenchidos via loadGeneratedFromDocId
- Pre-seleciona template, popula vars (sobrescreve auto-loaded vars
  com dados_preenchidos pra preservar customizacao anterior)
- Pula direto pra step 'edit'
- Save vira UPDATE in-place (preserva documents.id e audit trail)
- Header muda pra "Editar documento" + icone pi-pencil amber
- Botao final vira "Substituir documento"
- Doc sem registro generated (legado): toast info + flow normal de
  select template; ao salvar, cria o registro generated linkado.

MelissaPatientDocuments:
- onEdit substituido (era shortcut pra onPreview): abre generate dialog
  com editing-doc-id setado.
- Novo ref editingDoc dedicado (separado do selectedDoc que serve
  preview/share/sign/delete) pra evitar vazar "edit state" pro botao
  "Gerar" do header quando user so abre preview e fecha.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:42:39 -03:00
Leonardo 342defecde documents/generate: suporte a edicao in-place + linkage documento_id
document_generated.documento_id (FK pra documents) estava sempre NULL
no INSERT — sem isso nao da pra rastrear qual generated belongs to
qual documents row, impossibilitando re-edicao.

DocumentGenerate.service saveGeneratedDocument:
- Modo create (default): INSERT em documents PRIMEIRO pra capturar
  doc.id, depois INSERT em document_generated com documento_id setado.
- Modo edit (editingDocId param novo): UPDATE in-place — substitui
  PDF no Storage (novo path), atualiza bucket_path/tamanho/nome em
  documents (preserva id+audit), atualiza dados_preenchidos+pdf_path
  em document_generated. Se nao houver registro generated (doc legado),
  INSERT vinculando ao documents.id. Cleanup best-effort do PDF antigo.
- Nova fn loadGeneratedFromDocId(documentoId): busca template_id +
  dados_preenchidos pra pre-popular o dialog de edicao.

useDocumentGenerate.generateAndSave: ganha 2o param editingDocId que
passa pro service.

Backfill SQL pra docs antigos: match dg.pdf_path = d.bucket_path +
tenant/patient guard. 3 docs linkados no DB local, 5 ficaram orfaos
(paths que nao existem mais em documents — cleanup antigo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:42:24 -03:00
Leonardo fff70e4a71 saas-docs/tmp: SQL de import direto pra doc Cronometro
Script usado pra importar a doc 02-cronometro-melissa.json
diretamente no banco via psql (mesmo padrao da doc Busca global).
DO block com dollar quoting ($HTML$ e $FAQ$) pra evitar escape hell
no HTML conteudo + nos FAQs.

Importacao executada em 2026-05-22. Doc id=e87d4d33-7f5c-454e-a2ff-
0f92505b7c3c + 12 FAQ itens vinculados.

Path: database-novo/tmp/import-doc-cronometro.sql — pasta tmp pra
artefatos de operacao (nao parte do schema canonico).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:49:10 -03:00
Leonardo 550c4ade44 saas-docs: doc do Cronometro de sessao (Melissa)
Doc JSON com 10 secoes cobrindo: 3 jeitos de abrir (hero, timeline,
card proximo paciente), pre-selecao + autostart via evento, exibicao
de programado/atraso (sessionPlan), anatomia do dialog, minimizar
(chip no dock), parar (salva DB) vs fechar (descarta com confirm),
toque no fim, persistencia localStorage, regra "um cronometro por
vez", atividade livre sem paciente, mobile (chip sem nome).

12 FAQs incluindo o caso de uso central (Larissa chegou agora),
comportamento do X com sessao rodando, cronometro multi-aba,
significado do badge 'atrasada Xmin', etc.

categoria='Sessão', pagina_path='/melissa', ordem=2 (busca era 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:49:02 -03:00
Leonardo 473e0f026e melissa/layout: topbar->tray bottom-right + dock 4 builtins + mobile collapse
Tray no canto inferior direito (substitui o topbar band do topo):
busca + plan-DEV + bell + ajuda + cog. Sibling de .melissa-dock
(fora de .win11-summary) pra ficar sempre interativo mesmo com
secao aberta (que aplica blur+pointer-none). z-index 66 (acima
do dock=65). Em <md (768px) collapse parcial — bell/help/cog/
plan-DEV somem e viram popup vertical no botao ⋮; dot vermelho
no ⋮ quando ha notificacoes nao lidas. Search sempre visivel.

Dock: 4 builtins na ordem Agenda · Pacientes · WhatsApp · Financeiro
(antes so Agenda+WhatsApp). MRU (max 3) ganha @media (max-width:
767px) display:none — utility 'hidden' do Tailwind perdia pro
.dock-pin{display:grid} por ordem de carga. Divisor entre builtins
e pins user some em mobile se so houver MRU (que ja esta oculto).

Wire-ups das commits anteriores:
- ref melissaBuscaRef + provide('openMelissaBusca') pra acoes
  contextuais futuras (botao tray chama direto via ref)
- @goto-date no <MelissaBusca> -> onBuscaGotoDate via _callOnAgenda
- @iniciar-cronometro no <MelissaTimelineHoje> -> handler que abre
  o cronometro com sessionPlan + autostart; opcao (b) "ja ativo"
  mostra toast warn sem trocar paciente
- Card "Proximo paciente" troca CTA pra "Iniciar cronometro" quando
  emCurso E tem patient_id; @open chama o mesmo handler do timeline

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:41:45 -03:00
Leonardo 9f3a047d6d melissa/cronometro: pre-selecionar paciente + sessionPlan + confirm fechar
MelissaCronometro.abrir() agora aceita opts { pacienteId, autostart,
sessionPlan }. Retorna { opened, alreadyRunning, samePaciente, ... }
pra caller decidir o feedback. Estado sessionPlan { startH, endH }
exibe "Programado: HH:MM – HH:MM" sob o select + badge laranja
"atrasada Xmin" quando hNow > startH. Cronometro NAO auto-ajusta —
analista decide quando comecar/parar. Tick a 30s atualiza atraso.
sessionPlan persiste no localStorage junto com o snapshot.

X agora dispara confirmarFechar(): pede ConfirmDialog quando ha
sessao em andamento OU tempo decorrido nao salvo; fecha direto se
clean. Tooltip mudou pra "Encerrar sem salvar".

Chip minimizado: nome do paciente fica display:none em <md (mobile)
pra nao estourar largura do dock — icone + timer cobrem o essencial.

MelissaTimelineHoje: botao ⏱ overlay no canto sup. direito das pills
(horizontal + vertical) quando ev esta em curso E tem patient_id.
Pulso emerald sutil pra chamar atencao; @click.stop pra nao abrir
o evento. Novo emit iniciar-cronometro(ev).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:41:27 -03:00
Leonardo 8bf992910d melissa/busca-global: 'Ir para [data]' + Ctrl+K unificado
MelissaBusca ganha parser de data ('hoje', 'amanha', 'ontem',
DD/MM/YYYY) e card destacado azul "Ir para [data]" como primeiro
item do flatList. Quando query parseia como data, pula a RPC
search_global (nao busca paciente com nome '20/06'). Enter sem
selecao explicita pega o primeiro item — UX spotlight padrao.

Novo emit goto-date(date) capturado em MelissaLayout via helper
_callOnAgenda que abre a agenda se fechada e chama gotoDate exposto
pela MelissaAgenda (alias pro onBuscaGotoDate existente).

MelissaAgenda perde o popover proprio (MelissaAgendaSearchPopover
deletado), o ref searchPopover, o hotkey Ctrl+K local e
onBuscaSelectEvento. Ctrl+K agora vive so na MelissaBusca — evita
dois listeners no mesmo atalho. MelissaBusca expoe openDialog via
defineExpose pra a lupa do tray chamar.

MelissaPacientes: comment update mencionando o tray.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:41:12 -03:00
Leonardo fa2b431a56 melissa/hero: contagem 'cancelado/remarcado' no resumo do dia
Acrescenta sufixo "(x foi cancelado, x foi remarcado)" depois do chip
de atendimentos quando ha sessoes nesses status em eventosHojeReais.
Sufixo nao-clicavel, peso menor pra nao competir com o link do total.
Pluralizacao gramatical (1 foi / 2+ foram).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:41:00 -03:00
Leonardo eb42759979 melissa/settings+ajuda: fechar ao clicar fora
Popover Personalizar (cog) e drawer de Ajuda agora fecham quando o
user clica em qualquer lugar fora do panel. Listener mousedown em
capture, watch em open pra anexar/desanexar; ignora o proprio botao
trigger (data-ajuda-toggle pro ajuda; cogBtnEl ref pro settings) pra
nao fazer close+reopen. Tambem flipa o panel do settings de top-12
pra bottom-12 (cog agora vive no bottom da .melissa-tray).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:40:48 -03:00
Leonardo c17c547ed2 log: sessao 21/05 noite - Melissa Fase 2 UX iter + bug isFinite(null)
5 commits em paciente.documentos e documents/generate. Bug raiz dos
"campos vem vazios": isFinite(null) global retorna true, null.toFixed
crashava em loadAllVariables. Trocado por Number.isFinite (strict).

Proxima sessao retoma de Fase 2 (2.7-2.9 gerar PDF dentro da aba
Documentos do paciente).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:10:48 -03:00
Leonardo 4f05c2cf1b documents/generate: fix null.toFixed em loadAllVariables (isFinite global e enganoso)
loadVariables falhava com TypeError quando nao havia sessao
vinculada (agendaEventoId=null) E o user nao passava extras.valor.
Stack: 'Cannot read properties of null (reading toFixed)'.

Causa: usei isFinite() global em vez de Number.isFinite():
  isFinite(null) => true    (coerce: Number(null) === 0)
  Number.isFinite(null) => false

Como isFinite(null) retorna true, o codigo entrava no branch
`valorNum.toFixed(2)` e crashava. Com isso, loadAllVariables
inteiro estourava e variables.value zerava — explicando os
inputs todos vazios mesmo com paciente/perfil/clinica preenchidos.

Fix: trocar isFinite por Number.isFinite (versao strict, nao
coerce null/undefined/string).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:04:58 -03:00
Leonardo 512bcc979c documents/generate: debug log em loadAllVariables
User reportou que mesmo com profile/paciente/clinica preenchidos
os campos do dialog continuam vazios. Pra diagnosticar:

- Promise.all -> Promise.allSettled: nao mascara falha individual
- console.error por source que falhou (patient/session/therapist/clinic)
- console.log com payload completo em dev mode (ownerId, tenantId,
  patientId, agendaEventoId, valores carregados, errors)

Depois de identificar a causa esses logs ficam ou viram telemetria
estruturada.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:02:26 -03:00
Leonardo 61bb0d9c26 documents/generate: FloatLabel + map de origem nos inputs
Dois problemas reportados no dialog "Gerar documento":
1. Inputs usavam <label> + <InputText> simples, fora do padrao
   FloatLabel adotado no resto do app.
2. Quando o auto-preenchimento vinha vazio o user nao tinha onde
   ir cadastrar o dado.

Mudancas:
- TEMPLATE_VARIABLES ganha campo `source` em cada entrada com a
  descricao de onde o dado eh cadastrado (ex: "Perfil -> Registro
  Profissional"). Map canonico no DocumentTemplates.service.js.
- DocumentGenerateDialog refatorado:
  * FloatLabel variant="on" em todos os inputs
  * Banner no topo com contagem "X de Y preenchidos" (verde se 100%,
    amber se faltam dados)
  * Hint (`pi pi-link` + texto source) embaixo de cada campo vazio
    apontando onde cadastrar
  * Erro de carregamento renderizado dentro do step edit
  * Input ganha `invalid` quando vazio (borda destaque)
- useDocumentGenerate.loadVariables:
  * console.error em caso de excecao (era engolido em silencio)
  * mensagem amigavel quando loadAllVariables retorna tudo vazio
    (caso comum quando paciente/perfil/clinica estao incompletos)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:59:32 -03:00
Leonardo 6c39c58dc8 melissa/paciente-docs: drawer mobile herda tema (win11-root no portal)
Drawer teleportado pro body perdia as vars --m-* (definidas em
.win11-root no MelissaLayout), caia nos fallbacks hardcoded (#1a1d2e)
e ficava mais escuro que o resto do tema.

Fix:
- Wrapper .mpd-drawer-portal recebe class win11-root pra trazer as
  vars --m-* pro escopo teleportado.
- Vars locais --mpd-bg/--mpd-border/--mpd-text com cascata:
  --m-* (win11-root) -> --p-* (PrimeVue global) -> hardcoded.
  Respeita dark/light automaticamente.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:54:37 -03:00
Leonardo 4e1ebeba13 melissa/paciente-docs: fix drawer mobile (teleport body + style global)
Bug: drawer abria mas travava — sidebar interna nao aparecia.
Duas causas combinadas:

1. position:fixed preso em stacking context: o MelissaPaciente
   tem transform/filter num ancestral, fazendo o fixed virar
   relativo ao pai em vez da viewport.
2. Styles scoped: ate corrigir o stacking context, ao teleportar
   pro body os data-v scoped attrs sumiriam e o CSS nao aplicaria.

Fix:
- <Teleport to="body"> wrap nos elementos drawer + backdrop. Saem
  da arvore do componente e ficam no body raiz.
- Styles do drawer movidos pra um <style> NAO-scoped no fim do
  arquivo. Classes globais .mpd-mobile-drawer* garantem que
  aplique nos elementos teleportados (que perdem data-v).
- Fallbacks adicionados nas vars CSS (--m-bg-medium, --m-border,
  --m-text) caso o body nao tenha o tema melissa carregado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:48:50 -03:00
Leonardo 51c33e73b9 melissa/paciente: aba Documentos vira pagina nativa 2-col
Antes: <DocumentsListPage embedded /> reusava o componente do
Rail/Classic em modo embed — visual conflitava com o padrao
Melissa, sem agrupamento por tipo, scroll inconsistente.

Novo: MelissaPatientDocuments.vue (componente nativo 2-col
seguindo MelissaDocumentosTemplates):
- Sidebar esquerda: tipos de documento com contadores
  (Todos, Laudo, Receita, Exame, Termo assinado, Relatorio
  externo, Identidade, Convenio, Declaracao, Atestado,
  Recibo, Outro). Item ativo destaca primary; vazios em
  opacity 50%.
- Main direita: header com titulo do tipo + count, DataView
  com cards (DocumentCard reusado), paginacao automatica >12,
  empty states distintos (global vs filtrado).
- Header da pagina: botoes Refresh / Gerar / Upload (primary
  outlined no dark-friendly).
- Mobile <1024px: sidebar vira drawer com botao "Tipos" no
  header (espelha padrao MelissaBloqueios/Templates).

Reaproveita do features/documents:
- useDocuments composable
- DocumentCard, DocumentUploadDialog, DocumentPreviewDialog,
  DocumentGenerateDialog, DocumentSignatureDialog,
  DocumentShareDialog

MelissaPaciente.vue: import DocumentsListPage -> Melissa
PatientDocuments + uso na aba.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:44:44 -03:00
Leonardo 682840f355 patient cadastro: fix nav pra view individual + rename pra singular
Bug: no melissa, salvar paciente -> "Salvar e ver pacientes" caia
em /pages/access. Causa: patientsListRoute() so tinha branches
/therapist e /admin, jogava na rota errada que o guard rejeita
no contexto melissa.

Fix:
1. PatientCadastroDialog + ComponentCadastroRapido — funcao
   renomeada pra patientViewRoute(patientId). Branch /melissa
   redireciona pra /melissa/paciente?id=<id> (prontuario individual)
   quando ha id, ou /melissa/pacientes (lista) sem id.
2. Botao "Salvar e ver pacientes" -> "Salvar e ver paciente"
   (singular). Reflete a navegacao real: vai pro proprio paciente
   que acabou de salvar, nao pra lista.
3. onCreated pega data?.id || props.patientId pra montar a rota.

Comportamento melissa: salvar paciente -> abre /melissa/paciente
?id=<id> (prontuario). Therapist/admin segue indo pra lista
(comportamento pre-existente).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:29:46 -03:00
Leonardo c6105df98a melissa/templates: count badges usam .mdt-page__count (consistencia + fix dark)
Badges .mdt-section__count.is-info e .is-accent tinham o mesmo
problema do botao primary: bg solido com texto branco/cor primary
quebrava o contraste no modo escuro (texto sumia).

Trocados pelo .mdt-page__count (mesmo estilo do badge no header
da pagina) — usa var(--m-accent-soft) que adapta ao tema.

Tambem removido o CSS .mdt-section__count (e .is-info / .is-accent)
que ficou orfao.

Visual: numero do contador (17 globais, N tenant) com o mesmo
estilo do "17" no header — consistencia visual + dark mode safe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:11:28 -03:00
Leonardo 402def7539 melissa/templates: botoes primary viram outlined (fix dark mode)
Bug: no modo escuro o bg primary do botao --primary tornava o
texto branco ilegivel — cor primary clara contra fundo claro.

Fix: estilo outlined em vez de filled:
- background transparente
- border-color: var(--p-primary-color)
- color: var(--p-primary-color)
- hover: bg sutil 10% mix com primary

Mantem hierarquia visual (a borda destacada sinaliza acao primaria)
mas sem o conflito de contraste. Funciona em ambos os temas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:09:49 -03:00
Leonardo 5dc91614ad melissa/templates: titulo do card quebra em ate 3 linhas
.mdt-card__name: -webkit-line-clamp 1 -> 3 + word-break:break-word
+ line-height 1.3. Nomes longos (ex: "Termo de Consentimento Livre
e Esclarecido para Atendimento Online") cabem inteiros em ate 3
linhas, com elipses no final se passar.

.mdt-card max-height: 200px -> 240px pra acomodar o titulo mais
alto + tipo + descricao (2 linhas) + footer com variaveis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:07:43 -03:00
Leonardo 597f8c05d5 melissa/templates: remove ConfirmDialog duplicado (toast 2x bug)
Bug: duplicar template disparava toast 2x e criava 2 copias.
Causa: MelissaLayout ja monta <ConfirmDialog /> global. Quando
MelissaDocumentosTemplates tambem montava, o confirm.require()
do PrimeVue dispara em TODOS os ConfirmDialog ativos -> callback
do accept executa 2x.

Fix: remove o <ConfirmDialog /> local de MelissaDocumentosTemplates.
O global do MelissaLayout cobre tudo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:02:25 -03:00
Leonardo 79425a3c9a templates/editor: variavel mobile insere na posicao do cursor
Antes: variavel inseria sempre no fim do texto no mobile.
Causa: usavamos append direto no form (form[field] += tag) porque
o foco estava no drawer e Jodit.insertHTML travava.

Fix: capturar selection ANTES do drawer abrir, restaurar antes de
inserir.

JoditEmailEditor expose API estendida:
- saveSelection() -> retorna markers (jodit.selection.save())
- restoreSelection(markers) -> re-foca editor + restaura markers
- focus() -> foca o editor

DocumentTemplateEditor:
- ref savedSelection capturada em openDrawer('vars'): snapshot dos
  markers do Jodit no momento (cursor original)
- insertVariable mobile: setTimeout 280ms apos fechar drawer ->
  restaura markers -> insertHTML (cursor volta pra onde estava ->
  variavel aparece no ponto exato)
- Fallback append no form se restore falhar
- savedSelection limpa em fecharDrawer + apos insert

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:47:38 -03:00
Leonardo 87a1ac1358 templates/editor: defer insercao pos-transicao + pointer-events na saida
Trava persistia mesmo apos fix anterior. Causa raiz: append no form
ocorria durante a transicao CSS do drawer (250ms slide out). Vue
reagia ao v-model -> Jodit re-renderia HTML com nova variavel ->
concorrencia entre repaint do drawer saindo + reflow do Jodit
mobile = trava.

Fix:
1. setTimeout(280ms) — append no form so executa DEPOIS que a
   transicao do drawer terminou. Drawer sai limpo, depois Jodit
   re-renderiza isolado.
2. CSS: .dte-mobile-drawer:not(.is-open) ganha pointer-events:none
   durante saida. Evita captura de touch/click "perdidos" que
   tentavam triggerar handlers no drawer ja saindo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:42:40 -03:00
Leonardo 6860628087 templates/editor: fix trava do drawer ao inserir variavel no mobile
Bug: ao clicar numa variavel no drawer mobile, a variavel era
inserida mas o drawer travava/bugava. Causa: editor.insertHTML(tag)
do Jodit tenta resolver selection/cursor — no mobile, foco esta nos
botoes do drawer, nao no editor, entao Jodit fica em loop tentando
encontrar posicao.

Fix:
- Detecta isMobile e usa append direto via v-model
  (form.value[field] += tag) em vez de editor.insertHTML
- Fecha o drawer ANTES da insercao pra Jodit reconciliar com
  v-model na proxima tick
- No desktop, comportamento original (insertHTML mantem posicao
  do cursor) permanece

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:25:41 -03:00
Leonardo 134f562a1f templates/editor: drawer mobile com form + variaveis (tabs)
Mobile (<1024px): so o editor (col 2) fica visivel. Form de
metadados (col 1) e variaveis (col 3) viram tabs dentro de um
drawer fixed que abre pela esquerda.

Padrao espelhado de MelissaBloqueios/MelissaDocumentosTemplates,
com adaptacoes pra ser autocontido (sem dependencia do componente
pai).

Script:
- drawerOpen + drawerTab ('form' | 'vars') + isMobile refs
- _mqMobile matchMedia listener (onMounted setup +
  onBeforeUnmount cleanup)
- openDrawer(tab) / fecharDrawer helpers
- insertVariable agora fecha o drawer no mobile apos inserir

Template:
- Drawer wrap no inicio: tabs (Identificacao / Variaveis) +
  botao close + 2 panes (#dte-mobile-drawer-form e
  #dte-mobile-drawer-vars)
- Backdrop overlay com blur fecha o drawer
- Toolbar do editor ganha 2 botoes mobile-only (Identificacao /
  Variaveis) com classe dte-toolbar__mobile-actions
- <Teleport to="#dte-mobile-drawer-form" :disabled="!isMobile">
  envolvendo a <aside class="dte-side">
- <Teleport to="#dte-mobile-drawer-vars" :disabled="!isMobile">
  envolvendo a <aside class="dte-vars">

CSS:
- .dte-mobile-drawer fixed left, transform translateX, 250ms
- 2 panes scroll interno separado
- @media (max-width:1023px): cols vira 1-col, side/vars inline
  somem, botoes mobile aparecem, titulo canonico some

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:23:13 -03:00
Leonardo bbbb08ba9d melissa/templates: drawer mobile com templates do sistema
Mobile (<1024px) agora segue padrao MelissaBloqueios:
- Coluna esquerda (Templates do sistema) eh teleportada pra um
  drawer fixed que abre via botao "Templates do sistema" no header.
- Botao .mdt-menu-btn--mobile-only substitui o titulo no mobile
  (mais legivel + acao clara).
- Backdrop escuro com blur fecha o drawer ao clicar fora.
- Auto-fecha quando o user seleciona um template (libera viewport
  pra ver o preview no main).

Script:
- drawerOpen + isMobile refs + matchMedia listener
- toggleDrawer/fecharDrawer helpers
- onMounted setup + onBeforeUnmount cleanup

Template:
- <Transition name="mdt-drawer-fade"> wrap (slide horizontal +
  fade do backdrop)
- <Teleport to="#mdt-mobile-drawer-target" :disabled="!isMobile">
  envolvendo a <aside class="mdt-side">
- Botao "Menu" no header com class mdt-menu-btn--mobile-only

CSS:
- .mdt-mobile-drawer fixed left, transform translateX, 250ms cubic
- .mdt-mobile-drawer__backdrop overlay com blur
- @media (max-width: 1023px): cols vira 1-col, sidebar inline some,
  botao menu aparece, titulo canonico some, acções viram icone-only

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:19:17 -03:00
Leonardo 17f114f32f melissa/templates: usa DataView pros templates do tenant
Substitui o <div class="mdt-grid"> v-for simples por <DataView>
do PrimeVue na coluna "Seus documentos".

Beneficios:
- Paginacao automatica quando passa de 12 templates (era scroll
  infinito virando lento)
- Slot #grid permite manter o layout de cards atual
- Footer com paginator integrado ao design (border-top + bg
  transparente)

CSS:
- .mdt-dataview flex column ocupando o main
- :deep(.p-dataview-content) flex 1 + overflow auto = scroll
  interno dos cards
- :deep(.p-dataview-paginator-bottom) flex-shrink 0 = paginator
  sempre visivel no fundo
- .mdt-main .mdt-grid passa a ter padding 12 e gap 10 (era
  herdado do .mdt-grid global)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:11:10 -03:00
Leonardo c9afe8f009 templates/preview: background do papel cobre 100% da altura
Bug: container .dte-preview era display:flex + justify-content:center.
Em alguns navegadores, o flex limitava a altura intrinseca do
.dte-preview__doc (papel) quando o conteudo crescia — background
branco ficava com a altura do menor item e o conteudo "vazava".

Fix: container vira block normal com overflow-y:auto. Doc
centralizado via margin:0 auto (em vez de justify-content). Adiciona
box-sizing:border-box + height:auto + overflow:visible no doc pra
garantir que o background cresce com o conteudo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:04:21 -03:00
Leonardo c7e311b851 jodit: remove botoes hr, eraser e source (nao funcionavam)
Botoes da toolbar do corpo que nao tinham comportamento esperado:
- hr (linha horizontal)
- eraser (apagar formatacao)
- source (alternar HTML)

Removidos do array bodyButtons. layoutButtons (header/footer) ja
nao tinha esses 3 (era enxuta).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:03:24 -03:00
Leonardo 0aabea7753 templates/editor: corpo do editor ocupa 100% da altura disponivel
Antes: minHeight 450 em pixel fixo do Jodit limitava o corpo —
sobrava area vazia abaixo do editor.

Fix CSS-only (sem mexer no JoditEmailEditor compartilhado):

- .dte-main__editor: overflow hidden + flex column (era overflow-y
  auto). O scroll passa pra dentro do Jodit (workplace).
- .dte-editor-wrap: flex 1 + min-height 450 (preserva minimo).
- :deep(.jodit-container/workplace/wysiwyg) force flex + height
  100% + min-height 0/100% pra anular o height: 450px que o Jodit
  seta inline.

Resultado: editor sempre preenche toda area disponivel da COL 2,
expande/contrai com a janela, e o scroll do conteudo fica dentro
do proprio editor (jodit-wysiwyg).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:02:04 -03:00
Leonardo 80cce772db templates/editor: layout 3-col + tabs cabecalho/corpo/rodape
Refatora DocumentTemplateEditor em 3 colunas seguindo padrao
MelissaAgendaConfig:

- COL 1 (esquerda, 240-280px): form de metadados — nome, tipo,
  descricao (Textarea com autoResize), URL do logo
- COL 2 (centro, flex 1): sub-tabs Cabecalho/Corpo/Rodape, 1
  editor visivel por vez. Cada editor com minHeight: 450px (era
  120/350/120). Tab ativa destacada com border-bottom primary +
  background sutil.
- COL 3 (direita, 220-260px): variaveis agrupadas por categoria,
  hint dinamico mostrando qual sub-tab esta ativa ("Clique para
  inserir no Cabecalho/Corpo/Rodape"). Botoes com {{ }} braces
  em monospace + cor primary.

Scroll interno:
- .dte-page flex column, gap 12, min-height 0, padding 12
- Cada coluna eh card (border + radius) com header sticky + body
  scrollable interno (overflow-y: auto, scrollbar-width: thin)
- Variaveis com max-height proprio + scroll interno

Mobile (<1024px):
- 3-col vira 1-col stacked
- Container do .dte-cols ganha overflow-y auto (scroll da pagina
  inteira em vez de scroll interno em cada coluna)
- Variaveis ganha max-height 320px pra nao ocupar a tela toda

Preview (toggle no topo):
- Documento A4-like centralizado (max-width 794px ≈ 96dpi)
- Padding 48/56px, shadow sutil
- Mobile: padding reduzido pra max width disponivel

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:59:21 -03:00
Leonardo f1c24242e0 melissa/templates: ajustes UI dos cards e icones (4 fixes)
1. Icone "eye" do header de Preview -> cor primary (classe
   mdt-main__title-icon-eye).

2. Icone "ellipsis-v" (3 pontos) dos cards do tenant -> cor
   primary via :deep(.p-button-icon) selector.

3. Variaveis do card: formato "< 12 variaveis >" (entities
   HTML &lt;/&gt;) em font monospace + cor primary + bold.
   Removido o icone pi-code (a propria notacao < > sinaliza).

4. .mdt-card max-height: 200px + overflow hidden. Foot agora
   tem justify-content: center + margin-top: auto pra grudar
   no fundo do card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:53:08 -03:00
Leonardo b821db6438 melissa/templates: fix parse error em variaveis do preview
Erro: \"Unexpected token, expected }\" em \`{{ array.map(v => \`{{...${v}}}...\`) }}\`.
Vue parser confunde os \`{{\` da template string aninhada com os
delimitadores de interpolacao Vue, abortando parse.

Fix: extrai pra helper externo formatVarsPreview(vars, max) que
monta as chaves via concatenacao de strings (open + open + v +
close + close) — sem template literal com \`{{\` literal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:47:00 -03:00
Leonardo 0fafc28581 melissa/templates: layout 2-col + preview antes de duplicar
Refatora MelissaDocumentosTemplates seguindo padrao do
MelissaAgendaConfig (2-col com sidebar). Dois ajustes pedidos:

1. Layout 2-col (mdt-cols grid 360px + 1fr):
   - COL 1 (sidebar): "Templates do sistema" — lista vertical
     compacta com nome/tipo/descricao. Click abre preview.
   - COL 2 (main): "Seus documentos" + subtitulo + grid de cards
     dos templates do tenant.
   - Empty states distintos por coluna.
   - Mobile (<900px): empilha 1-col.

2. Preview antes de duplicar:
   - View 'preview' nova (alem de list/create/edit).
   - Click num template do sistema -> view='preview' (substitui
     "Seus documentos" no main, sidebar permanece pra navegar).
   - Header da main muda: nome do template + tipo/desc + 2 botoes
     (Voltar / Duplicar).
   - Iframe sandbox=allow-same-origin renderiza HTML completo
     (cabecalho+corpo+rodape com CSS basico A4-like).
   - Footer com lista de variaveis {{...}} do template (5 +N).
   - Item ativo na sidebar destaca borda primary + opacity 1 no
     icone de visualizar.
   - Pos-duplicar: volta pra view='list' pra mostrar o novo
     template no main.

UX result: user le antes de copiar (evita lixo em "Seus documentos"
de copias que nao queria).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:42:05 -03:00
Leonardo 75e67eae5d docs/templates: move pra Configurações (3 layouts)
Templates de documentos sao "setup", nao operacao diaria — deveriam
viver em Configuracoes, nao no menu de Documentos do paciente.

Mudancas:

1. Melissa — melissaConfigGrupos.js ganha grupo "Documentos" com
   1 item "Modelos de documentos" -> slug `documentos-templates`
   (pagina nativa MelissaDocumentosTemplates ja existe + ja esta
   wired no MelissaLayout linha 2896).

2. Rail/Classic — routes.configs.js ganha rota
   /configuracoes/documentos/templates (name=ConfiguracoesDocumentos
   Templates) apontando pro mesmo DocumentTemplatesPage.vue.

3. Rotas antigas removidas — routes.therapist.js e routes.clinic.js
   nao tem mais /documents/templates nem nomes de rota
   therapist-documents-templates / admin-documents-templates.
   URLs antigas dao 404 (decisao do user — limpa).

4. ConfiguracoesPage (sidebar Rail/Classic) ganha grupo
   "Documentos" antes do "Empresa & Plataforma" com item "Modelos
   de documentos".

5. Menus de pacientes (therapist.menu + clinic.menu) NAO tem mais
   "Templates" — caminho de acesso e Configuracoes.

6. pagesIndex.js (busca global) atualizado: novo path, novos
   keywords (recibo, atestado, laudo, tcle, lgpd, consent), roles
   ['therapist','admin'].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:11:33 -03:00
Leonardo 9a6eb56827 saas-docs/tmp: SQL de import direto pra doc Busca global
Script usado pra importar a doc 01-busca-global-melissa.json
diretamente no banco via psql (sem passar pelo botao "Importar
JSON" da UI). DO block com dollar quoting pra evitar escape hell
no HTML conteudo + nos FAQs (que contem aspas, kbd, etc).

Importacao executada. Doc id=d9d2e431-0bd7-4883-9cfa-3a1a3228c295
+ 12 FAQ itens vinculados.

Path: database-novo/tmp/import-doc-busca.sql — pasta tmp pra
artefatos de operacao (nao parte do schema canonico).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:51:09 -03:00
Leonardo 652571da69 saas-docs: doc da Busca global + Recently viewed (Fase 1 melissa)
Primeira doc gerada do plano de testes layout Melissa. Segue o
template do prompt em SaasDocsPage.vue:
- titulo, conteudo HTML rico (cards reproduzidos visualmente),
  categoria=Navegacao, pagina_path=/melissa
- 12 FAQs cobrindo: atalho Ctrl+K, busca por telefone/CPF, lista
  acessados recentemente, privacidade local-only, threshold 2
  chars, cores semanticas por categoria, navegacao por teclado,
  documentos por nome paciente, limpar localStorage, sessoes
  passadas/futuras
- Nota pro dev: componente MelissaBusca.vue nao tem id= em nenhum
  elemento — sugestoes de IDs pra adicionar quando ativar
  data-highlight links.

Path: development/saas-docs/01-busca-global-melissa.json
Pronto pra importar via /saas/docs "Importar JSON".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 13:14:02 -03:00
Leonardo 30367392ff busca melissa: troca popover por Dialog Spotlight (CMD+K pattern)
Refatora MelissaBusca pra usar PrimeVue Dialog em vez de popover
absolute. Resolve definitivamente o bug do panel estourar viewport
quando ha muitos resultados.

Mudancas:

1. Trigger no dock: input -> <button> com aparencia de input. Clica
   ou Ctrl+K abre Dialog. Mantem placeholder + Ctrl+K kbd hint.

2. Dialog Spotlight: 640px max-width, posicionado 10vh do topo
   (estilo Spotlight macOS / Linear / GitHub). Backdrop blur escuro,
   dismissable mask, sem header, sem closable button (Esc cobre).

3. Input REAL dentro do Dialog: autofocus on open via nextTick.
   Mantem v-model="query" + @keydown="onKeydown" (Arrow/Enter).

4. Panel de resultados: era position:absolute com max-height:60vh
   (estourava em layouts com input perto do bottom). Agora vive
   DENTRO do Dialog (flex:1, max-height:70vh no content), scroll
   interno garantido por design — conteudo NUNCA passa do bottom
   da pagina.

5. Remove: onClickOutside (dismissableMask cobre), Transition
   mb-fade (Dialog tem sua animacao).

Comportamento end-user identico (Ctrl+K, navegacao com setas, Enter
seleciona, Esc fecha) mas visual + manutencao muito melhor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:52:19 -03:00
Leonardo b40116fe5d busca melissa: panel usa surface do tema (resolve branco-em-branco)
Texto branco hardcoded ficava ilegivel no tema claro do PrimeVue
(branco em branco). Tema escuro funcionava ok pq fundo era escuro.

Fix: troca cores hardcoded por CSS tokens do tema:
- mb-panel background: var(--surface-card)
- mb-panel border: var(--surface-border)
- mb-item color: var(--text-color)
- mb-item__sub color: var(--text-color-secondary)
- mb-group__title color: var(--text-color-secondary) com opacity
- mb-item hover: color-mix com p-primary-color 8%

Icones semanticos (patient pink, sessao indigo, doc sky, intake
orange) ficam mais saturados no tema claro e suavizados no escuro
via :root.app-dark selectors.

Input field do search bar mantem fallback `white` — ele fica no
shell escuro do Melissa (lockscreen-style), nao depende do tema
PrimeVue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:30:09 -03:00
Leonardo ffd8eab72d busca melissa: shape RPC + cores legíveis + cor de sessão
3 fixes pedidos no teste manual:

1. Shape errado da RPC: search_global retorna { id, label, sublabel,
   deeplink } pra TODOS os tipos, mas o codigo lia campos diretos
   (nome_completo, paciente_nome, inicio_em, nome_original etc) que
   nao existem -> resultados saiam "(sem nome)", sem datas.
   Fix: filteredPacientes + rpcAppointments + rpcDocuments + rpcIntakes
   agora usam label/sublabel direto. selectEntry extrai patient_id da
   deeplink quando precisa.

2. Cores ilegiveis: fundo do panel transparente demais (var(--m-bg-medium)
   nao tinha contraste em alguns temas). Fix: fundo solido rgba(20,22,32,
   0.92), border 14% white, text 96% white pra label, 65% pra sub
   (sobe pra 78% no hover/active). Group title 50% + bold pra hierarquia
   clara.

3. Cor das sessoes: grupo "Sessoes" tinha icone cinza generico. Fix:
   classes .mb-item__icon--{patient,sessao,doc,intake} com paleta
   espelhando a agenda — sessao = indigo-500 (#a5b4fc texto +
   rgba(99,102,241,0.20) bg, mesma cor do pickColor() padrao);
   patient = pink-400; doc = sky-500; intake = orange-400.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:26:24 -03:00
Leonardo dee89ccd84 registro profissional: campo livre quando tipo='outro'
Quando o profissional seleciona "Outro" no Tipo de registro, agora
aparece um campo adicional pra informar o nome do conselho/instituicao
livre (ex: APM, ABRAP, conselhos nao-listados).

Migration 20260521000009 adiciona profiles.professional_registration_
type_other (text livre). Aplicada e marcada no _db_migrations.

ProfilePage e MelissaPerfil:
- form.professional_registration_type_other no reactive
- SELECT/UPDATE inclui a nova coluna
- UI condicional: campo aparece SOMENTE quando type === 'outro'
- Preview ao vivo usa type_other no lugar de 'outro' quando aplicavel
- Save limpa type_other automaticamente quando troca pra outro tipo

DocumentGenerate.service.loadTherapistData puxa type_other da query.
Quando profile.type='outro', terapeuta_registro_tipo recebe o valor
livre (ex: 'APM 12345/SP' em vez de 'outro 12345/SP'). terapeuta_crp
(legacy compat) continua so preenchido quando type RAW = 'CRP'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:26:21 -03:00
Leonardo 6a8ee52ad8 offline-overlay: trava falso positivo em dev/HMR + rede instavel
Problema: overlay "Sem conexao" aparecia toda hora em dev. Causa:
fetch('/favicon.ico') com timeout 4s + poll a cada 10s + sem retry.
Qualquer slow request (vite HMR rebuild, DNS, network blip) marcava
offline imediato.

Fixes:

1. Confia em navigator.onLine PRIMEIRO. Se browser ja sinaliza
   offline (wifi caiu, modo aviao), pula o fetch — fonte 100%
   autoritativa.

2. Threshold de 2 falhas consecutivas. Antes 1 falha = offline.
   Agora precisa 2 consecutivas, descarta blips esporadicos.
   Reset pra 0 a cada success.

3. Timeout fetch 4s -> 8s. Mais tolerante a slow requests.

4. Poll 10s -> 30s (prod) ou 60s (dev). Reduz pressao no Vite HMR
   sem perder detectividade. Eventos offline/online do browser
   continuam capturando mudancas reais instantaneamente.

5. Em DEV, polling 60s (vs 30s prod). HMR rebuilds podem demorar;
   queremos minimizar fetch concorrente.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:17:27 -03:00
Leonardo 7516468f78 melissa/perfil: ajustes UX nos cards novos
4 fixes pedidos no teste manual:

1. Card "Registro profissional" movido pra apos Identidade (em vez
   de antes do Layout). Faz sentido contextual — dados pessoais
   profissionais ficam juntos.

2. Inputs do Registro convertidos pra FloatLabel variant="on"
   (padrao Melissa do resto da tela). Tres campos: tipo, numero, uf
   + preview box.

3. Card "Preferencias" tema agora em 1 linha (grid 2-col fixo,
   classe .mpr-theme-row). Antes podia quebrar em 2 linhas via
   flex-wrap.

4. "Trocar senha" navega pra /melissa/seguranca (rota nativa
   Melissa, MelissaSeguranca.vue ja existente) em vez de
   /account/security (que sairia do shell Melissa). Nao vaza mais
   pro layout classico.

Styles novos extraidos do inline pro <style scoped>: mpr-preview-box,
mpr-theme-row, mpr-theme-card, mpr-info-row, mpr-action-card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:14:29 -03:00
Leonardo 20d2b3aee4 melissa/perfil: 3 cards novos — registro CFP + preferencias + seguranca
Espelha as melhorias do ProfilePage no perfil nativo Melissa
(/melissa/perfil), com 4 changes:

1. Card "Registro Profissional" (id=mpr-sec-registro, antes do
   card Layout): Select tipo + Number + Select UF + preview ao vivo
   "Aparecera nos documentos como: CRP 06/12345/SP". 3 colunas de
   migration 20260521000003 wire-up no load + save.

2. Card "Layout" — sub do Rail atualizado pra mensagem solicitada:
   "Icones no canto esquerdo + painel expansivel. Disponivel apenas
   no desktop."

3. Card "Preferencias" (id=mpr-sec-preferencias, depois do Layout):
   toggle Tema Claro vs Escuro com cards visuais + sun/moon icons.
   Usa isDarkTheme + toggleDarkMode do useLayout.

4. Card "Seguranca" (id=mpr-sec-seguranca, ultimo): mostra e-mail
   atual readonly + botao "Trocar senha" que navega pra
   /account/security.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:04:32 -03:00
Leonardo ae1e1388b9 profile: UI dos campos de registro profissional (CFP #5)
Gap detectado em teste manual: migration 20260521000003 adicionou
as 3 colunas (professional_registration_type/_number/_uf) e o
DocumentGenerate.service.loadTherapistData ja le delas, mas a UI
de edicao nao foi criada.

ProfilePage.vue ganha novo card "Registro Profissional" (id=
registro-profissional, cor #0ea5e9 ciano, antes do card de Redes
Sociais):
- Select tipo (CRP/CRM/CRFa/CREFITO/CRESS/CRN/RMS/outro — mesmas
  opcoes do CHECK constraint)
- InputText numero
- Select UF (27 estados, filterable)
- Preview: "Aparecera nos documentos como: CRP 06/12345/SP"
- Numero e UF disabled enquanto tipo nao escolhido

Wire-up: SELECT/UPDATE do profile agora incluem as 3 colunas.
form.* tem defaults vazios.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:54:51 -03:00
Leonardo 4024469952 db: aplica 13 migrations + 3 seeds + ext config + gotcha doc
Aplica no banco local todas as migrations pendentes do dia (clinical
notes, accept_invite RPC, asaas tables/rls, profiles registration,
specialties, document_templates consent types, sign_document RPCs,
list_my_signatures, recibo amend) e os 3 seeds novos (clinical note
templates, specialties, consent forms LGPD/Gravacao).

db.config.json estendido com os 3 seeds novos (system group) pra
setup do zero rodar tudo.

Gotcha re-validado: migration 20260521000005 (CHECK constraint
em document_templates) silenciosamente falhou via db.cjs porque
postgres nao e owner da tabela (owned por supabase_admin). Detectado
quando seed_060 falhou com violates check constraint. Re-rodada
via TCP 127.0.0.1 trust com `psql -U supabase_admin`. Memoria
project_supabase_admin_gotcha atualizada com o metodo correto.

Sanity check pos-aplicacao:
- 5 RPCs novas + 8 tabelas novas
- 17 document_templates global (15 + 2 LGPD/Gravacao)
- 34 specialties + 6 clinical_note_templates
- Backup automatico em backups/2026-05-21/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:19:45 -03:00
51 changed files with 6157 additions and 982 deletions
+165
View File
@@ -14,6 +14,86 @@ Chronological, append-only record of everything that's happened in this wiki.
---
## [2026-05-22 dia] session | Melissa UX overhaul + 5 docs saas (Fases 2-5)
Touched: none codigo durable; 5 docs saas novas em development/saas-docs/
Sessao longa (~12 commits codigo + 5 docs). 2 grandes blocos:
BLOCO 1 — Melissa UI overhaul (manha):
- Tray no canto inf. direito (substitui topbar band do topo): busca +
plan-DEV + bell + ajuda + cog. Sibling de .melissa-dock (fora de
.win11-summary) pra ficar interativa com secao aberta. Em <md (768px)
collapse parcial — bell/help/cog/plan-DEV viram popup vertical no
botao ⋮; dot vermelho no ⋮ quando ha notificacoes nao lidas.
- Busca global unificada: MelissaBusca ganha parser de data (hoje/
amanha/ontem/DD/MM/YYYY) + card azul "Ir para [data]" + emit
goto-date. Popover da agenda (MelissaAgendaSearchPopover) deletado;
Ctrl+K so vive na MelissaBusca. Lupa unica fica so na .melissa-tray
(removida das toolbars de secoes pra evitar pollution mobile).
- Dock: 4 builtins (Agenda · Pacientes · WhatsApp · Financeiro). MRU
oculto em <md via @media (utility 'hidden' do tailwind perdia pro
.dock-pin{display:grid} por carga).
- Hero resumo: contagem "(x foi cancelado, x foi remarcado)" depois
do chip atendimentos com gramatica plural.
- Settings + Ajuda fecham ao clicar fora (mousedown capture + watch
open). Cog ref + data-ajuda-toggle ignoram trigger pra evitar
close+reopen.
- Cronometro: pre-selecao paciente + autostart quando aberto via
botao ⏱ na timeline (sessao em curso) ou card "Proximo paciente".
abrir(opts) com { pacienteId, autostart, sessionPlan }. sessionPlan
exibe "Programado: HH:MM HH:MM" + badge "atrasada X min"; NAO
desconta atraso auto. Confirm fechar quando ha sessao rodando/
decorrido sem salvar. Chip minimizado oculta nome do paciente em
<md (so icone + tempo).
- Documents: linkage document_generated.documento_id agora preenchido
no INSERT (era sempre NULL). Modo edit in-place via editingDocId:
busca template+dados_preenchidos via loadGeneratedFromDocId, popula
vars, pula pra step 'edit'; save substitui PDF no Storage e
atualiza documents (preserva id+audit). Header amber "Editar
documento" + botao "Substituir documento". Backfill SQL pra docs
antigos (3 linkados, 5 orfaos no DB local).
- DocumentPreviewDialog: wire-up dos 5 botoes da sidebar (download/
editar/share/sign/delete) que estavam caindo no vazio.
BLOCO 2 — saas-docs (tarde):
Padrao igual da 01-busca-global-melissa.json — JSON-fonte +
SQL de import direto via $HTML$/$FAQ$ dollar quoting. 5 docs novas
(03 a 07), cada uma com 12 FAQ itens:
- 03 Documentos do paciente — pagina_path /melissa/paciente,
categoria Documentos
- 04 Templates de documentos — pagina_path /melissa/documentos-
templates, categoria Documentos
- 05 Assinatura eletronica — pagina_path /melissa/paciente,
categoria Documentos
- 06 Recibo profissional — pagina_path /melissa/agenda, categoria
Financeiro (cobre fluxo do AgendaEventoFinanceiroPanel)
- 07 Relatorios e exportacao — pagina_path /melissa/relatorios,
categoria Relatorios
Todas importadas no DB local via docker exec psql. Total acumulado:
7 docs ativas em saas_docs (busca + cronometro + os 5 novos).
PROXIMA SESSAO (retomar 23/05):
- Fase 6 RESTANTE: C12 UX iter (cronometro/sessao antecipar pgto —
flow DB ja ok, UX obscura adiada em 20/05). Unico item de codigo
da lista de ontem.
- Fase 7 RESTANTE: Regressao Agenda C7-C13 (validacao manual; eu
nao executo, so listo plano de teste se quiser).
- Antes/depois: olhada no ROADMAP.md canonico pra panorama MVP
real. Itens visiveis ainda no horizonte: #12 papel timbrado
(bloqueado, codigo no UniaoApp), #15 NFS-e (esforco L), §1.5
Sentry+qualidade, Asaas Fase B (bloqueado), M4 cutover billing
(depende decisoes #2/#3/#6), validacao centralizada CPF/CNPJ/tel.
ITENS TESTADOS HOJE (✅): tray + busca unificada + cronometro
evento-aware + edicao in-place de docs gerados + Fase 2.7-2.9
(gerar PDF, vars CRP/UF, tipo_documento='outro').
PUSH: 12 commits pushados (c17c547..701d9f4) usando workaround SSL
(git -c http.sslVerify=false push). Credenciais pediram 1x, depois
cacheou pra sessao toda.
## [2026-05-20 18:30] session | C12 deferred + C13 prep (lock ja existia em Fase 6)
Touched: none (codigo + HANDOFF; memoria project_c12_antecipar_iterar)
Detalhes:
@@ -1430,6 +1510,37 @@ closure.
Pendente: Fase C (adapter Rail) + Fase D (adapter Clinica) +
doc ajuda.
## [2026-05-21 dawn] session | migrations + seeds aplicados no banco local
Touched: none
Aplicou todas as 13 migrations pendentes do dia (clinical_notes
tables/rls/versioning + documents link + accept_invite RPC + asaas
tables/rls + profiles registration + specialties + document_templates
consent types + sign_document RPCs + list_my_signatures + recibo
amend) + 3 seeds novos (seed_040 clinical_note_templates 6 entries +
seed_050 specialties 34 entries + seed_060 consent forms 2 templates
LGPD/Gravacao + amend tcle_online).
Gotcha re-validado (memoria atualizada): migration 20260521000005
estendendo CHECK dt_tipo_check foi marcada aplicada pelo db.cjs mas
silenciosamente ROLLBACK (postgres nao e owner de document_templates).
Detectado quando seed_060 falhou com violates check constraint.
Re-rodada via `docker exec -i ... sh -c 'psql -U supabase_admin -h
127.0.0.1 -d postgres'` (trust pra 127.0.0.1/32 em pg_hba.conf).
db.config.json estendido com os 3 seeds novos (system group, ordem
seed_040 -> seed_050 -> seed_060) pra setup do zero rodar tudo.
Sanity check pos-aplicacao:
- 5 RPCs novas (accept_tenant_invite + 3 sign + list_my_signatures)
- 8 tabelas novas (clinical_notes + versions + templates + asaas
customers/payments/webhook + profile_specialties + specialties)
- 17 document_templates global (15 existentes + 2 LGPD/Gravacao)
- 34 specialties seedadas
- 6 clinical_note_templates seedados
- 3 colunas professional_registration_* em profiles
- Backup automatico criado em backups/2026-05-21/
## [2026-05-21 deep night] session | agenda Fases C + D — Rail+Clinica adotam billing core
Touched: none
@@ -1477,3 +1588,57 @@ TOTAL DA SESSAO (24/05 - 25/05, ~24 commits):
- Agenda decomposicao A+B1+B2: -991L em useMelissaAgenda (~33%)
- Agenda Fases C+D: Rail+Clinica adotam billing core
- useAgendaStatusChange composable novo
## [2026-05-21 23:00] session | Melissa Fase 2 UX iter + bug isFinite(null)
Touched: feedback_isfinite_strict, feedback_teleport_body_styles
Detalhes:
Sessao de testes manuais Fase 2 (templates + paciente.documentos).
4 ajustes UX + 1 bug funcional resolvido. 5 commits, 0 push (SSL
self-signed Gitea — user faz manual amanha).
1) MelissaPatientDocuments (4e1ebeb, 6c39c58):
Aba Documentos no /melissa/paciente?id=X foi convertida de embed
<DocumentsListPage> pra pagina nativa 2-col Melissa. Drawer mobile
bugava (transform/filter em ancestrais trapando position:fixed).
Fix:
- <Teleport to="body"> no drawer + backdrop pra escapar stacking
- styles do drawer movidos pra <style> nao-scoped (teleport perde
data-v attrs do scoped)
- wrapper teleportado recebe class "win11-root" pra herdar vars
--m-* (definidas nesse escopo no MelissaLayout)
- cascata --mpd-bg/border/text: --m-* -> --p-* -> hardcoded
2) DocumentGenerateDialog (61bb0d9, 512bcc9):
Inputs trocados pra FloatLabel variant="on". Adicionado map de
ORIGEM dos campos (TEMPLATE_VARIABLES.source) — hint embaixo de
cada campo vazio explica onde cadastrar (ex: "Perfil -> Registro
Profissional"). Banner verde/amber no topo conta preenchidos.
3) Bug critico (4f05c2c) — RAIZ do "campos vem vazio mesmo com
profile preenchido":
loadAllVariables crashava com TypeError "Cannot read properties
of null (reading toFixed)" quando NAO havia sessao vinculada
(agendaEventoId=null) E sem extras.valor. Toda a Promise
estourava, variables zerava.
Causa: isFinite(null) global retorna TRUE (Number(null)===0),
entrava no branch valorNum.toFixed e crashava.
Fix: trocar por Number.isFinite (strict, nao coerce).
Salvo como memoria feedback_isfinite_strict.
PROXIMA SESSAO (retomar amanha 22/05):
- Continuar Fase 2: 2.7-2.9 (gerar PDF dentro da aba Documentos
do paciente, conferir vars CRP/UF preenchem, doc aparece como
tipo_documento='outro')
- Gerar JSON docs Fase 2 (#6 + templates page)
- Fase 3: Portal assinatura #7
- Fase 4: Recibo profissional #14 testes
- Fase 5: Relatorios export #13
- Fase 6: C12 UX iter (deferred 20/05)
- Fase 7: Regressao Agenda C7-C13
PUSH PENDENTE: 35 commits ahead of origin/main; SSL self-signed
do Gitea exige `git -c http.sslVerify=false push origin main`
+ credenciais (user faz manual).
+4 -1
View File
@@ -22,7 +22,10 @@
"seed_015_document_templates.sql",
"seed_030_dev_phases_items.sql",
"seed_031_dev_auditoria.sql",
"seed_032_dev_competitors.sql"
"seed_032_dev_competitors.sql",
"seed_040_clinical_note_templates.sql",
"seed_050_specialties.sql",
"seed_060_consent_forms_extra.sql"
],
"test_data": [
"seed_020_test_data.sql"
@@ -0,0 +1,21 @@
-- ============================================================================
-- Compliance CFP #5 — campo livre quando tipo de registro = 'outro'
-- ----------------------------------------------------------------------------
-- Migration 20260521000003 adicionou professional_registration_type com CHECK
-- limitado a 8 valores (CRP/CRM/CRFa/CREFITO/CRESS/CRN/RMS/outro). Quando o
-- profissional escolhe 'outro', precisa informar qual conselho/instituição
-- (ex: associações privadas, conselhos não-listados).
--
-- Esta migration adiciona professional_registration_type_other (text livre),
-- que só é preenchido quando type = 'outro'.
-- ============================================================================
BEGIN;
ALTER TABLE public.profiles
ADD COLUMN IF NOT EXISTS professional_registration_type_other text;
COMMENT ON COLUMN public.profiles.professional_registration_type_other IS
'Nome livre do conselho/instituição quando professional_registration_type = ''outro''. Aparece em recibos/laudos no lugar do tipo padrão.';
COMMIT;
@@ -0,0 +1,39 @@
-- Backfill: linkar document_generated.documento_id em registros antigos
-- pra suportar re-edicao in-place de documentos gerados.
--
-- O codigo novo (DocumentGenerate.service.js saveGeneratedDocument) ja
-- preenche o documento_id no INSERT pra criacoes novas. Este script eh
-- one-off pra docs gerados ANTES desse fix.
--
-- Match: dg.pdf_path = d.bucket_path + match de tenant/patient pra evitar
-- linkar a doc errado em caso colidente. Registros sem match (paths que
-- nao existem mais em documents — docs deletados/cleanup) ficam orfaos
-- com documento_id=NULL: nao quebra nada, so nao tem caminho de re-edit.
BEGIN;
UPDATE public.document_generated dg
SET documento_id = d.id
FROM public.documents d
WHERE dg.documento_id IS NULL
AND dg.pdf_path = d.bucket_path
AND dg.patient_id = d.patient_id
AND dg.tenant_id = d.tenant_id
AND d.deleted_at IS NULL;
-- Relatorio pos-backfill
DO $REPORT$
DECLARE
v_linked int;
v_orphans int;
BEGIN
SELECT count(*) FILTER (WHERE documento_id IS NOT NULL),
count(*) FILTER (WHERE documento_id IS NULL)
INTO v_linked, v_orphans
FROM public.document_generated;
RAISE NOTICE 'document_generated: % linked, % orphans (sem documents correspondente)',
v_linked, v_orphans;
END;
$REPORT$;
COMMIT;
@@ -0,0 +1,166 @@
-- Importacao da doc Assinatura eletronica de documentos (Fase 3 #7)
-- Gerado a partir de development/saas-docs/05-assinatura-eletronica-melissa.json
BEGIN;
DO $IMPORT$
DECLARE
v_doc_id uuid;
BEGIN
INSERT INTO public.saas_docs (
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
pagina_path, ordem, ativo, medias
) VALUES (
'Assinatura eletrônica de documentos',
$HTML$<h2>Assinatura eletrônica de documentos</h2>
<p>O sistema permite enviar documentos clínicos (TCLE, contratos, autorizações, laudos) pro paciente assinar <strong>sem que ele precise ter login</strong>. O fluxo registra a assinatura com hash do conteúdo, IP, user-agent e timestamp gerando um audit trail compliance LGPD/CFP.</p>
<h3>1. Visão geral do fluxo</h3>
<ol>
<li><strong>Terapeuta</strong> abre o documento no prontuário e clica em <em>Assinar</em></li>
<li>Adiciona os signatários (nome + email) e ativa <em>"Gerar link público para assinatura"</em></li>
<li>Sistema cria signature requests + um <strong>link público temporário</strong> com token</li>
<li>Terapeuta copia a URL e envia pro paciente (WhatsApp, email, SMS manual por enquanto)</li>
<li><strong>Paciente</strong> abre o link em qualquer navegador, o documento, marca o checkbox de aceite LGPD e clica <em>Assinar</em></li>
<li>Sistema computa SHA-256 do PDF baixado, registra assinatura via RPC server-side (IP/UA capturados pelo banco)</li>
<li>Terapeuta o status atualizado no documento (pendente assinado)</li>
</ol>
<h3>2. Lado terapeuta criar solicitação</h3>
<p>No preview de um documento (na aba <em>Documentos</em> do prontuário), clique no botão <strong>Assinar</strong> na sidebar de ações. O <em>DocumentSignatureDialog</em> abre com:</p>
<ul>
<li><strong>Lista de signatários:</strong> adicione um ou mais pra cada um, nome + email são obrigatórios. O paciente principal vem pré-preenchido se disponível.</li>
<li><strong>Toggle "Gerar link público para assinatura"</strong> (default ON): cria um share_link junto com as signature requests. Sem isso, fica a request registrada o paciente precisa logar no portal pra assinar.</li>
<li><strong>Select de validade do link:</strong> 24h / 3 dias / 7 dias / 30 dias. Default 7 dias (168h).</li>
<li><strong>Submit:</strong> cria as requests + link e mostra a URL pronta pra copiar.</li>
</ul>
<h3>3. Lado paciente link público (sem login)</h3>
<p>Ao abrir o link <code>/shared/document/:token</code>, o paciente :</p>
<ul>
<li><strong>Preview do PDF</strong> inline (iframe)</li>
<li><strong>Painel azul</strong> embaixo do preview com:
<ul>
<li>Aviso LGPD/CFP explicando o que vai ser registrado</li>
<li>Checkbox <em>"Li o documento e concordo com seu conteúdo"</em> (bloqueia botões até marcado)</li>
<li>Select de signatário (se houver mais de um cadastrado)</li>
<li>Botões <strong>Assinar</strong> (emerald) e <strong>Recusar</strong> (rose)</li>
</ul>
</li>
</ul>
<p>Ao clicar Assinar:</p>
<ol>
<li>Sistema baixa o PDF e computa <strong>SHA-256</strong> client-side (proof of integrity se o doc foi alterado depois, hash não bate)</li>
<li>Chama a RPC <code>sign_document_by_token</code> passando o hash</li>
<li>RPC captura <strong>IP via inet_client_addr()</strong> e <strong>user-agent via current_setting('request.headers')</strong> server-side, à prova de spoof client-side</li>
<li>Registra em <code>document_signatures</code>: timestamp, hash, IP, UA, status='assinado'</li>
<li>Mostra tela de confirmação "Documento assinado com sucesso"</li>
</ol>
<h3>4. Audit trail registrado</h3>
<p>Cada assinatura grava:</p>
<ul>
<li><strong>signatario_id</strong> (se o signatário tem cadastro como paciente vinculado)</li>
<li><strong>signatario_nome</strong> e <strong>signatario_email</strong> (do que foi cadastrado pelo terapeuta)</li>
<li><strong>assinado_em</strong> timestamp da RPC (server time, não client clock)</li>
<li><strong>assinatura_hash</strong> SHA-256 do PDF no momento da assinatura</li>
<li><strong>ip_address</strong> capturado server-side (anti-spoof)</li>
<li><strong>user_agent</strong> header HTTP via current_setting</li>
<li><strong>status</strong> pendente / enviado / assinado / recusado / expirado</li>
</ul>
<div style="background: rgba(16,185,129,0.08); border: 1px solid rgba(16,185,129,0.3); border-radius: 10px; padding: 12px 14px; margin: 14px 0; font-size: 0.85rem;">
<strong>🛡 Por que server-side?</strong> Capturar IP/UA no banco via <code>inet_client_addr()</code> é anti-spoof: o cliente não consegue forjar valores arbitrários. Garante que o audit trail reflete a sessão HTTP real, não um POST manipulado.
</div>
<h3>5. Recusar a assinatura</h3>
<p>O paciente pode <strong>recusar</strong> em vez de assinar útil se ele não concorda com o conteúdo. Click em <strong>Recusar</strong> abre um <em>confirm</em>; ao confirmar, o sistema registra a recusa (com timestamp + IP/UA da mesma forma) e marca a request como <code>status='recusado'</code>. O terapeuta isso na lista de signature requests e pode entrar em contato pra ajustar o documento.</p>
<h3>6. Portal do paciente lista de pendências</h3>
<p>Pacientes logados no portal (<code>/portal/documentos</code>) veem uma lista de TODOS os documentos solicitados pra eles, com KPIs no topo:</p>
<ul>
<li><strong>Total</strong> · <strong>Pendentes</strong> · <strong>Assinados</strong> · <strong>Recusados</strong></li>
</ul>
<p>Filtro por status (todos / pendentes / assinados) + lista. Click em <strong>Assinar agora</strong> num item pendente leva pro <code>/shared/document/:token</code> (mesma página pública, mas com auth garantida via portal).</p>
<h3>7. Expiração e múltiplos usos</h3>
<ul>
<li><strong>Validade do link:</strong> configurada na criação (24h/3d/7d/30d). Após expirar, retorna 410 Gone. Terapeuta pode gerar novo link pelo botão <em>Compartilhar</em> no preview do doc.</li>
<li><strong>Limite de usos:</strong> calculado como <code>max(signatários × 3, 5)</code> gerar 1 signatário 5 usos disponíveis (margem de erro / reload / multi-device).</li>
<li><strong>Cada assinatura é única:</strong> mesmo signatário não consegue assinar 2x a RPC bloqueia se houver registro com status=assinado.</li>
</ul>
<h3>8. Múltiplos signatários</h3>
<p>Documentos como termo de autorização de menor podem precisar de 2+ assinaturas (responsável legal + paciente menor, ou os dois pais). O dialog aceita N signatários; cada um recebe sua própria entry em <code>document_signatures</code>. O link público é o mesmo quando o paciente abre, escolhe qual signatário ele é no select e assina apenas a sua entry.</p>
<h3>9. Validade legal</h3>
<p>A assinatura eletrônica registrada pelo sistema atende:</p>
<ul>
<li><strong>LGPD (Lei 13.709/2018):</strong> consentimento explícito registrado com timestamp, IP e UA base legal Art. 7º I</li>
<li><strong>Código de Ética CFP:</strong> documento clínico com identificação inequívoca do signatário (nome+email+IP+hash)</li>
<li><strong>MP 2200-2/2001 (ICP-Brasil):</strong> assinatura <em>simples</em> com integridade via hash não é certificado A1/A3, mas é válida pra documentos sem exigência ICP</li>
</ul>
<p> Pra documentos que exigem ICP-Brasil (notarial, procuração com poderes especiais), use uma plataforma externa de assinatura qualificada esse fluxo não substitui.</p>
<h3> Notas pro desenvolvedor</h3>
<ul>
<li><strong>RPCs:</strong> <code>sign_document_by_signature_id</code> (paciente logado no portal), <code>sign_document_by_token</code> (link público), <code>get_signable_document_by_token</code> (resolve token doc + signature_request), <code>list_my_signatures</code> (lista do paciente, cruza por signatario_id, signatario_email e patient.user_id)</li>
<li><strong>Service:</strong> <code>DocumentSignatures.service.js</code> com wrappers <code>signByPortal</code>, <code>signByToken</code>, <code>getSignableDocumentByToken</code>, <code>listMySignatures</code>, <code>hashDocument</code>, <code>refuseSignature</code>, <code>createSignatureRequests</code>, <code>createShareLink</code>, <code>buildShareUrl</code></li>
<li><strong>Composable:</strong> <code>useDocumentSignatures</code> (Tipo A blueprint)</li>
<li><strong>UI lado terapeuta:</strong> <code>DocumentSignatureDialog.vue</code> (component)</li>
<li><strong>UI lado paciente:</strong> <code>PortalDocumentos.vue</code> (portal logado) + <code>SharedDocumentPage.vue</code> (link público)</li>
<li><strong>Notificação automática</strong> (paciente recebe email/WA quando signature criada) pendente, depende de Módulo 6 (notifications factory channel)</li>
</ul>$HTML$,
'Documentos',
true,
'usuario',
'/melissa/paciente',
5,
true,
'[{"tipo": "imagem", "url": ""}]'::jsonb
)
RETURNING id INTO v_doc_id;
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
(v_doc_id, 'Como peço pra um paciente assinar um documento (TCLE, contrato, autorização)?',
$FAQ$Na aba <strong>Documentos</strong> do prontuário, clique no doc no preview, clique em <strong>Assinar</strong> (sidebar de ações). O dialog abre. Adicione o paciente como signatário (nome + email), mantenha <em>"Gerar link público para assinatura"</em> marcado, escolha validade (7 dias é o default) e clique em <strong>Solicitar</strong>. O sistema cria a request e mostra uma URL pra copiar. Envie pro paciente via WhatsApp, email, SMS como preferir.$FAQ$, 0, true),
(v_doc_id, 'Como o paciente assina sem ter login no sistema?',
$FAQ$Ele abre o link público (<code>/shared/document/:token</code>) em qualquer navegador. o PDF inline, o aviso LGPD/CFP, marca o checkbox <em>"Li o documento e concordo"</em>, e clica <strong>Assinar</strong>. O sistema computa hash SHA-256 do PDF, chama a RPC server-side que captura IP/User-Agent e registra a assinatura. Nada de cadastro, nada de senha.$FAQ$, 1, true),
(v_doc_id, 'Que informação fica registrada quando ele assina?',
$FAQ$Tudo que precisa pra audit compliance: <strong>nome e email</strong> do signatário (do cadastro), <strong>timestamp server-side</strong> (não do relógio do cliente), <strong>hash SHA-256 do PDF</strong> no momento da assinatura (qualquer alteração posterior invalida a integridade), <strong>IP</strong> e <strong>User-Agent</strong> capturados pelo banco via <code>inet_client_addr()</code> e <code>current_setting('request.headers')</code> anti-spoof. Tudo fica em <code>public.document_signatures</code>.$FAQ$, 2, true),
(v_doc_id, 'O terapeuta também precisa assinar o documento?',
$FAQ$Depende do tipo. Pra atestados, laudos e declarações, geralmente sim você gera o PDF a partir do template (que contém seu nome + registro profissional + assinatura digitalizada se você incluiu no rodapé). Pra contratos e termos com paciente como contraparte, você adiciona você mesmo como segundo signatário no dialog antes de enviar. Cada um abre o link e assina sua entry separadamente.$FAQ$, 3, true),
(v_doc_id, 'O link tem validade? E se expirar?',
$FAQ$Tem. Você escolhe 24h, 3 dias, 7 dias ou 30 dias na hora de criar (default 7d). Depois disso o link retorna erro 410 Gone. Se o paciente não assinou a tempo, gere um novo link: no preview do doc, clique em <strong>Compartilhar</strong> ou abra o dialog de assinatura novamente vai criar outro token. Limite de usos do link: ~5 (margem pra reload/multi-device), depois também expira.$FAQ$, 4, true),
(v_doc_id, 'E se o paciente recusar a assinatura?',
$FAQ$Tem botão <strong>Recusar</strong> ao lado do <em>Assinar</em>. Clique pede confirmação; ao confirmar, a request fica com <code>status='recusado'</code> com timestamp e IP/UA registrados igual à assinatura. Você o status na lista de pendências do doc e na aba do prontuário. Geralmente: ajuste o conteúdo do documento e envie nova solicitação.$FAQ$, 5, true),
(v_doc_id, 'O paciente pode assinar depois pelo Portal sem precisar do link?',
$FAQ$Sim, se ele tem conta de portal. Em <strong>/portal/documentos</strong> aparece a lista de tudo que está pendente pra ele assinar, com KPIs (total, pendentes, assinados, recusados) e botão <strong>Assinar agora</strong> que leva pra mesma página de assinatura. Útil pra pacientes que perderam o link no WhatsApp eles loga e acha tudo num lugar .$FAQ$, 6, true),
(v_doc_id, 'Como compartilho o link com o paciente — tem envio automático?',
$FAQ$Hoje o envio é <strong>manual</strong>: o dialog gera a URL, você copia e cola onde quiser (WhatsApp, email, SMS, AirDrop, QR code, link em conversa direta). Envio automático (notificação por WA/email quando signature é criada) está no roadmap, depende do Módulo 6 (notifications factory channel) que ainda não foi implementado.$FAQ$, 7, true),
(v_doc_id, 'A assinatura tem validade legal mesmo sem certificado ICP-Brasil?',
$FAQ$Pra documentos clínicos comuns (TCLE, contrato de prestação, autorizações, declarações entre terapeuta-paciente), <strong>sim</strong>. A assinatura simples com timestamp + hash + IP + UA atende LGPD (Art. 7º I consentimento explícito) e o Código de Ética do CFP. Pra documentos que exigem certificado ICP-Brasil (notarial, procuração com poderes especiais), use uma plataforma externa de assinatura qualificada esse fluxo não substitui.$FAQ$, 8, true),
(v_doc_id, 'O paciente consegue editar o documento antes de assinar?',
$FAQ$Não. O paciente <strong> visualiza</strong> o PDF é renderizado em iframe e a integridade é garantida pelo hash SHA-256 computado no momento da assinatura. Se o conteúdo precisar mudar, é você que ajusta o documento (editar via template ou regenerar) e envia nova solicitação. A assinatura antiga (se houve) fica registrada com o hash do conteúdo antigo o doc atual tem hash diferente, mostrando que mudou.$FAQ$, 9, true),
(v_doc_id, 'Como cancelo uma solicitação de assinatura?',
$FAQ$Hoje não um botão "cancelar" direto na UI. O caminho é: ignore (deixa expirar pelo prazo do link) ou peça pro admin marcar como <code>status='expirado'</code> no banco. Em versões futuras teremos botão de cancelar na lista de pendências do doc.$FAQ$, 10, true),
(v_doc_id, 'Posso pedir mais de uma pessoa pra assinar o mesmo documento?',
$FAQ$Sim. Pra termos com múltiplos signatários (autorização de atendimento de menor com 2 pais, contrato com responsável legal + paciente), adicione cada um como signatário separado no dialog. Cada um vira uma entry em <code>document_signatures</code>. O link público é o mesmo quando o signatário abre, escolhe quem ele é no select acima dos botões e assina apenas a entry dele. Útil também pra você incluir si mesmo (terapeuta) + paciente num contrato bilateral.$FAQ$, 11, true);
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
END;
$IMPORT$;
COMMIT;
+168
View File
@@ -0,0 +1,168 @@
-- Importação da doc Fase 1 (Busca global + Recently viewed)
-- Gerado a partir de development/saas-docs/01-busca-global-melissa.json
BEGIN;
DO $IMPORT$
DECLARE
v_doc_id uuid;
BEGIN
-- 1) Cria a doc principal
INSERT INTO public.saas_docs (
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
pagina_path, ordem, ativo, medias
) VALUES (
'Busca global e Acessados recentemente',
$HTML$<h2>Busca global no Layout Melissa</h2>
<p>A <strong>busca global</strong> é o atalho mais rápido para encontrar pacientes, sessões, documentos e cadastros recebidos sem precisar navegar pelos menus. Você acessa pelo <em>dock central</em> do Layout Melissa ou usando o atalho de teclado <kbd>Ctrl</kbd> + <kbd>K</kbd> (em qualquer página do Melissa).</p>
<h3>1. Como abrir</h3>
<p>Localize o campo de busca no dock central do Melissa. Ele aparece como um botão com o ícone de lupa e o placeholder <em>"Buscar paciente, agenda, atalho…"</em>, com o atalho <kbd>Ctrl K</kbd> indicado no canto direito.</p>
<div style="border: 1px solid #cbd5e1; border-radius: 12px; padding: 0 14px; height: 44px; max-width: 480px; display: flex; align-items: center; gap: 10px; background: #1e2333; color: #cbd5e1; font-family: 'Segoe UI', sans-serif; margin: 12px 0;">
<i class="pi pi-search" style="font-size: 0.95rem;"></i>
<span style="flex: 1; font-size: 0.9rem;">Buscar paciente, agenda, atalho</span>
<span style="font-size: 0.62rem; padding: 2px 7px; border-radius: 4px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.15); letter-spacing: 0.05em;">Ctrl K</span>
</div>
<p><strong>Três jeitos de abrir:</strong></p>
<ul>
<li>Clicando no campo no dock central</li>
<li>Pressionando <kbd>Ctrl + K</kbd> (Windows/Linux) ou <kbd> + K</kbd> (Mac) em qualquer página</li>
<li>Pelo menu lateral, opção "Buscar" (quando disponível)</li>
</ul>
<h3>2. O Dialog Spotlight</h3>
<p>Ao abrir, o sistema mostra um <strong>diálogo centralizado</strong> com o input grande no topo e os resultados em colunas abaixo. Isso é o padrão Spotlight (igual ao usado em macOS, Linear, GitHub, Slack).</p>
<div style="background: var(--surface-card, #fff); border: 1px solid #e2e8f0; border-radius: 14px; max-width: 520px; box-shadow: 0 12px 32px rgba(0,0,0,0.15); overflow: hidden; margin: 12px 0; font-family: 'Segoe UI', sans-serif;">
<div style="padding: 14px 18px; border-bottom: 1px solid #e2e8f0; display: flex; align-items: center; gap: 12px;">
<i class="pi pi-search" style="color: #64748b;"></i>
<span style="flex: 1; color: #94a3b8; font-size: 1.05rem;">Buscar paciente, agenda, atalho</span>
<span style="font-size: 0.65rem; padding: 2px 8px; border-radius: 4px; background: #f1f5f9; border: 1px solid #e2e8f0; color: #64748b;">Esc</span>
</div>
<div style="padding: 6px;">
<div style="text-transform: uppercase; letter-spacing: 0.18em; color: #64748b; font-size: 0.62rem; font-weight: 700; padding: 8px 10px 4px; opacity: 0.75;">Acessados recentemente</div>
<div style="display: flex; align-items: center; gap: 10px; padding: 9px 10px; border-radius: 8px;">
<span style="width: 32px; height: 32px; display: grid; place-items: center; border-radius: 7px; background: rgba(244,114,182,0.18); color: #ec4899; font-size: 0.9rem;">
<i class="pi pi-user"></i>
</span>
<div style="flex: 1;">
<div style="font-size: 0.88rem; font-weight: 500;">André Green</div>
<div style="font-size: 0.74rem; color: #64748b;">andre@email.com</div>
</div>
<i class="pi pi-arrow-right" style="color: #94a3b8; font-size: 0.75rem;"></i>
</div>
</div>
</div>
<h3>3. Onde a busca procura</h3>
<p>Digitando <strong>pelo menos 2 caracteres</strong>, o sistema dispara uma busca completa em 5 categorias:</p>
<ul>
<li><strong style="color: #ec4899;">Pacientes</strong> por nome completo, e-mail, telefone ou CPF</li>
<li><strong style="color: #6366f1;">Sessões</strong> por título ou nome do paciente, em qualquer data</li>
<li><strong style="color: #0ea5e9;">Documentos</strong> por nome do arquivo ou descrição</li>
<li><strong style="color: #f97316;">Cadastros recebidos</strong> solicitações de novos pacientes pendentes</li>
<li><strong>Atalhos</strong> ações rápidas como "Agenda", "Financeiro", etc.</li>
</ul>
<p>Cada categoria aparece com um <strong>ícone colorido distinto</strong> para facilitar a leitura visual. Os resultados são limitados aos 6 mais relevantes por categoria.</p>
<h3>4. Como navegar nos resultados</h3>
<p>Você pode usar o mouse ou o teclado:</p>
<ul>
<li><kbd></kbd> / <kbd></kbd> navegar entre os resultados</li>
<li><kbd>Enter</kbd> abrir o item selecionado</li>
<li><kbd>Esc</kbd> fechar o diálogo</li>
<li><kbd>Clique no backdrop</kbd> fecha também</li>
</ul>
<h3>5. Acessados recentemente</h3>
<p>Quando você abre a busca <strong>sem digitar nada</strong>, a primeira seção mostra <strong>"Acessados recentemente"</strong> os últimos 5 pacientes que você visitou (em qualquer dispositivo deste navegador).</p>
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px 16px; margin: 12px 0; font-size: 0.88rem; color: #475569;">
<strong>💡 Dica:</strong> Use Ctrl+K + Enter para reabrir o último paciente acessado em 2 segundos.
</div>
<p>Esses 5 pacientes ficam salvos no seu navegador (não no banco de dados), então:</p>
<ul>
<li>São <strong>privados</strong> outros usuários não veem</li>
<li>São <strong>por navegador</strong> se trocar do Chrome pro Firefox, a lista recomeça</li>
<li><strong>Persistem</strong> após fechar o navegador (localStorage)</li>
<li>Auto-rotacionam: ao acessar o 6º paciente, o mais antigo sai</li>
</ul>
<h3>6. Clique nos resultados</h3>
<p>Ao clicar:</p>
<ul>
<li><strong>Paciente</strong> abre o prontuário (<code>/melissa/paciente?id=</code>)</li>
<li><strong>Sessão</strong> abre o evento na agenda</li>
<li><strong>Documento</strong> abre o prontuário do paciente na aba Documentos</li>
<li><strong>Cadastro recebido</strong> vai pra lista de Cadastros recebidos</li>
<li><strong>Atalho</strong> navega pra seção (Agenda, Financeiro, etc.)</li>
</ul>
<h3>7. Tema claro × escuro</h3>
<p>O Dialog adapta automaticamente as cores conforme o tema escolhido em <strong>Meu Perfil Preferências</strong>. Texto, fundos e bordas seguem as configurações do sistema. Apenas os ícones por categoria (paciente rosa, sessão índigo, documento azul, cadastro laranja) mantêm a mesma cor para preservar a identificação visual rápida.</p>
<h3> Notas pro desenvolvedor</h3>
<p>Atualmente o componente <code>MelissaBusca.vue</code> <strong>não tem atributos <code>id</code></strong> em seus elementos. Para o sistema de highlight da ajuda funcionar (links <code>data-highlight</code>), sugere-se adicionar:</p>
<ul>
<li><code>id="melissa-busca-trigger"</code> no botão de trigger no dock</li>
<li><code>id="melissa-busca-dialog"</code> no Dialog</li>
<li><code>id="melissa-busca-input"</code> no input dentro do Dialog</li>
<li><code>id="melissa-busca-recent"</code> no grupo de Acessados recentemente</li>
</ul>$HTML$,
'Navegação',
true,
'usuario',
'/melissa',
1,
true,
'[{"tipo": "imagem", "url": ""}]'::jsonb
)
RETURNING id INTO v_doc_id;
-- 2) Insere os 12 FAQ items vinculados
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
(v_doc_id, 'Como abrir a busca rapidamente?',
$FAQ$Use o atalho <kbd>Ctrl + K</kbd> (Windows/Linux) ou <kbd> + K</kbd> (Mac) em qualquer página do Melissa. Você também pode clicar diretamente no campo de busca no dock central.$FAQ$, 0, true),
(v_doc_id, 'Posso buscar paciente por telefone ou CPF?',
$FAQ$Sim. A busca de pacientes encontra pelo <strong>nome completo, e-mail, telefone ou CPF</strong>. Digite pelo menos 2 caracteres e aguarde os resultados.$FAQ$, 1, true),
(v_doc_id, 'O que aparece em "Acessados recentemente"?',
$FAQ$Os últimos 5 pacientes que você abriu pelo prontuário, em ordem do mais recente pro mais antigo. A lista aparece quando você abre a busca <strong>sem digitar nada</strong>.$FAQ$, 2, true),
(v_doc_id, 'Outros usuários veem meus "Acessados recentemente"?',
$FAQ$Não. A lista é <strong>privada e local</strong> fica salva apenas no seu navegador atual (localStorage). Se você logar em outro navegador ou computador, a lista começa vazia naquele dispositivo.$FAQ$, 3, true),
(v_doc_id, 'Quantos caracteres preciso digitar pra começar a buscar?',
$FAQ$<strong>Pelo menos 2</strong>. Buscas de 1 caractere são muito amplas e não disparam pesquisa. A partir de 2 caracteres, o sistema aguarda 200ms (tempo de digitação) antes de consultar o banco assim você não dispara dezenas de buscas digitando rápido.$FAQ$, 4, true),
(v_doc_id, 'Por que minha busca não retorna nada?',
$FAQ$Verifique: (1) digitou pelo menos 2 caracteres; (2) o termo está sem erros graves de digitação (a busca tolera pequenas variações via similarity); (3) o paciente/sessão realmente existe no seu cadastro. Se persistir, faça uma busca mais ampla ex: apenas o primeiro nome.$FAQ$, 5, true),
(v_doc_id, 'O que cada cor de ícone significa?',
$FAQ$Cada categoria tem uma cor própria: <strong style="color: #ec4899;">Rosa</strong> = Paciente, <strong style="color: #6366f1;">Índigo</strong> = Sessão da agenda, <strong style="color: #0ea5e9;">Azul</strong> = Documento, <strong style="color: #f97316;">Laranja</strong> = Cadastro recebido pendente. Atalhos vêm em cinza neutro.$FAQ$, 6, true),
(v_doc_id, 'Como navegar pelos resultados sem usar o mouse?',
$FAQ$Use as setas do teclado <kbd></kbd> e <kbd></kbd> para navegar entre os itens e <kbd>Enter</kbd> para abrir o selecionado. Pra fechar sem selecionar, use <kbd>Esc</kbd>.$FAQ$, 7, true),
(v_doc_id, 'Posso buscar documentos pelo nome do paciente?',
$FAQ$Sim. A busca de documentos cruza pelo nome do arquivo, descrição e <strong>nome do paciente vinculado</strong>. Ao clicar num resultado de documento, você é levado direto pra aba Documentos do prontuário daquele paciente.$FAQ$, 8, true),
(v_doc_id, 'Como limpar a lista de "Acessados recentemente"?',
$FAQ$Hoje não um botão na interface a lista é gerenciada automaticamente (limite de 5, mais antigo cai quando você acessa um novo). Pra limpar manualmente, você pode apagar os dados do site no seu navegador (Configurações Privacidade Limpar dados de navegação escopo "localStorage").$FAQ$, 9, true),
(v_doc_id, 'A busca encontra sessões antigas ou só as de hoje?',
$FAQ$Encontra sessões de <strong>qualquer data</strong> passadas e futuras. O grupo "Agenda de hoje" mostra apenas as do dia atual (preview rápido); o grupo "Sessões" inclui todas as outras encontradas no banco. Cada item mostra a data e horário da sessão.$FAQ$, 10, true),
(v_doc_id, 'Os atalhos (Agenda, Financeiro, etc.) sempre aparecem?',
$FAQ$Sim. Quando o campo está vazio, mostramos 4 atalhos padrão. Conforme você digita, os atalhos que combinam com sua busca permanecem visíveis (junto com os resultados do banco).$FAQ$, 11, true);
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
END;
$IMPORT$;
COMMIT;
+168
View File
@@ -0,0 +1,168 @@
-- Importacao da doc do Cronometro de sessao (Melissa)
-- Gerado a partir de development/saas-docs/02-cronometro-melissa.json
BEGIN;
DO $IMPORT$
DECLARE
v_doc_id uuid;
BEGIN
-- 1) Cria a doc principal
INSERT INTO public.saas_docs (
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
pagina_path, ordem, ativo, medias
) VALUES (
'Cronômetro de sessão',
$HTML$<h2>Cronômetro de sessão</h2>
<p>O <strong>cronômetro de sessão</strong> acompanha o tempo decorrido durante o atendimento e é integrado com a agenda. Quando aberto a partir de uma sessão em andamento, ele vem com o paciente pré-selecionado e dispara automaticamente.</p>
<h3>1. Três jeitos de abrir</h3>
<ul>
<li>Pelo botão <strong></strong> ao lado do relógio gigante do dashboard (abre vazio, escolha o paciente ou deixe como atividade livre)</li>
<li>Pelo botão <strong></strong> que aparece sobre os cards de sessão em curso na timeline horizontal/vertical do dashboard</li>
<li>Pelo CTA <strong>"Iniciar cronômetro"</strong> no card <em>"Próximo paciente"</em> quando a sessão está em andamento</li>
</ul>
<p>Os dois últimos pré-selecionam o paciente da sessão e disparam o timer automaticamente.</p>
<h3>2. Sessão em curso na timeline</h3>
<p>Quando uma sessão entra em andamento (horário atual entre início e fim do evento), aparece um ícone <strong></strong> pulsando no canto superior direito do card do evento. O pulso é sutil, em verde pra sinalizar que pra cronometrar dali.</p>
<div style="display: flex; align-items: center; gap: 8px; background: #6366f1; color: white; padding: 4px 10px; border-radius: 4px; max-width: 320px; position: relative; margin: 12px 0; font-family: 'Segoe UI', sans-serif;">
<span style="font-size: 0.85rem; font-weight: 600;">11:00 Larissa Almeida</span>
<span style="position: absolute; top: 3px; right: 3px; width: 22px; height: 22px; display: grid; place-items: center; background: rgba(0,0,0,0.45); border: 1px solid rgba(255,255,255,0.4); border-radius: 999px; color: white; box-shadow: 0 0 0 4px rgba(16,185,129,0.25);">
<i class="pi pi-stopwatch" style="font-size: 0.7rem;"></i>
</span>
</div>
<p>Clicar no <strong></strong> <strong>não abre o evento</strong> abre o cronômetro pré-configurado pra essa sessão.</p>
<h3>3. Programado vs tempo real</h3>
<p>Quando aberto via timeline ou card "Próximo paciente", o cronômetro mostra o <strong>horário programado original da sessão</strong> sob o select de paciente. Se você abriu depois do horário previsto, aparece um badge laranja <strong>"atrasada X min"</strong>.</p>
<div style="background: rgba(15,23,42,0.85); color: #cbd5e1; padding: 14px; border-radius: 10px; max-width: 360px; font-family: 'Segoe UI', sans-serif; margin: 12px 0;">
<label style="font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.15em; color: rgba(255,255,255,0.5); display: block; margin-bottom: 8px;">Paciente / atividade</label>
<div style="background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15); padding: 9px 14px; border-radius: 10px; font-size: 0.9rem;">Larissa Almeida</div>
<div style="display: flex; align-items: center; gap: 6px; margin-top: 8px; padding: 4px 0;">
<i class="pi pi-calendar" style="font-size: 0.7rem; color: rgba(255,255,255,0.55);"></i>
<span style="font-size: 0.78rem; color: rgba(255,255,255,0.7);">Programado: 11:00 11:50</span>
<span style="margin-left: 4px; padding: 1px 8px; border-radius: 999px; background: rgba(251,146,60,0.18); color: rgb(253,186,116); font-size: 0.7rem; font-weight: 500; border: 1px solid rgba(251,146,60,0.35);">atrasada 8 min</span>
</div>
</div>
<p> <strong>O cronômetro NÃO desconta o tempo de atraso automaticamente.</strong> Ele conta a duração configurada cheia (50min padrão) a partir do clique. A info "atrasada" é pra você decidir se quer encerrar antes ou estender.</p>
<h3>4. Anatomia do dialog</h3>
<ul>
<li><strong>Header:</strong> rótulo "Cronômetro" + status (<em>Pronto</em> / <em>Em andamento</em> / <em>Pausado</em>)</li>
<li><strong>Botão Minimizar:</strong> recolhe pro chip no dock</li>
<li><strong>Botão X (Encerrar sem salvar):</strong> descarta a sessão (com confirmação se houver atividade)</li>
<li><strong>Select de paciente:</strong> pode trocar manualmente; opção <em>"— Atividade livre"</em> pra usos sem paciente</li>
<li><strong>Programado + badge de atraso:</strong> aparece quando aberto via evento da agenda</li>
<li><strong>Display gigante mm:ss:</strong> vira vermelho quando passa do tempo planejado (mostra <code>-mm:ss</code>)</li>
<li><strong>±5 min:</strong> estende ou encurta o tempo configurado a qualquer momento</li>
<li><strong>Botão grande inferior:</strong> Começar / Parar</li>
</ul>
<h3>5. Minimizar e restaurar</h3>
<p>Click no <strong>botão minimizar</strong> (ou click fora do dialog) <strong>recolhe</strong> o cronômetro pra um <strong>chip flutuante no dock</strong> (canto inferior esquerdo, ao lado do ψ). O timer continua rodando em background. Click no chip restaura o dialog em tela cheia.</p>
<div style="display: inline-flex; align-items: center; gap: 10px; padding: 8px 14px 8px 12px; background: rgba(15,23,42,0.85); border: 1px solid rgba(255,255,255,0.18); border-radius: 999px; color: #cbd5e1; font-family: 'Segoe UI', sans-serif; box-shadow: 0 8px 24px rgba(0,0,0,0.25); margin: 12px 0;">
<i class="pi pi-stopwatch" style="font-size: 0.85rem; color: #6ee7b7;"></i>
<span style="font-variant-numeric: tabular-nums; font-weight: 500; font-size: 0.85rem;">48:13</span>
<span style="font-size: 0.72rem; color: rgba(255,255,255,0.6); padding-left: 6px; border-left: 1px solid rgba(255,255,255,0.18);">Larissa Almeida</span>
</div>
<p>Em mobile (telas &lt;768px), o chip mostra o ícone + tempo sem o nome do paciente pra caber no dock estreito. O nome continua acessível ao restaurar.</p>
<h3>6. Parar (salva) vs Fechar (descarta)</h3>
<p>Duas ações diferentes pra terminar a escolha importa:</p>
<ul>
<li><strong> Parar</strong> (botão grande inferior): encerra a contagem e <strong>SALVA o tempo decorrido no banco</strong> (evento <code>session-end</code> com elapsed em segundos). Caminho normal de fim de sessão.</li>
<li><strong>X Encerrar sem salvar</strong> (header): descarta. Pede <strong>confirmação</strong> se sessão em andamento ou tempo decorrido não fecha por acidente. Se o cronômetro está limpo (não iniciado, sem tempo), fecha direto.</li>
<li><strong>Click fora / Minimizar</strong>: NÃO encerra. Esconde o dialog e mantém o timer rodando como chip no dock.</li>
</ul>
<h3>7. Quando o tempo acaba</h3>
<p>Aos <strong>50 minutos cronometrados</strong> (ou conforme configurado), o sistema toca um <strong>som curto</strong> uma única vez. O display vira <strong>vermelho</strong> e continua contando em negativo (mostra <code>-mm:ss</code>). <strong>Não corte automático</strong> você decide quando parar.</p>
<p>O toque pode ser trocado em <strong>Configurações Cronômetro Som de término</strong>. Opções: sino, gong, soft, silêncio.</p>
<h3>8. Persistência (reload-safe)</h3>
<p>Se você fechar a aba ou recarregar o navegador com cronômetro ativo, ao voltar o sistema <strong>retoma exatamente de onde parou</strong> descontando automaticamente o tempo passado entre fechar e abrir. O snapshot fica no <code>localStorage</code> do navegador, atualizado a cada mudança de estado.</p>
<p><strong>Limite de segurança:</strong> se passar de 24h sem voltar à aba, o restore não acumula o tempo perdido (proteção contra mudanças do relógio do sistema).</p>
<h3>9. Cronômetro ativo</h3>
<p>Existe <strong>um cronômetro por vez</strong>. Se você clicar no <strong></strong> de outra sessão enquanto cronômetro rodando, o sistema mostra um toast <strong>"Cronômetro já ativo"</strong> com o nome do paciente atual <strong>e não troca</strong>. Pare o cronômetro atual antes de iniciar outro.</p>
<h3>10. Atividade livre (sem paciente)</h3>
<p>Você pode abrir o cronômetro sem paciente (botão do dashboard, sem clicar em evento específico) e selecionar <em>"— Atividade livre (sem paciente)"</em> no dropdown. Útil pra:</p>
<ul>
<li>Pausa cronometrada</li>
<li>Pomodoro pessoal</li>
<li>Atendimento informal não cadastrado</li>
</ul>
<p>Atividade livre <strong>não emite session-end</strong> ao parar (não paciente pra vincular o tempo).</p>
<h3> Notas pro desenvolvedor</h3>
<p>Atualmente o componente <code>MelissaCronometro.vue</code> <strong>não tem atributos <code>id</code></strong> em seus elementos. Para o sistema de highlight da ajuda funcionar (links <code>data-highlight</code>), sugere-se adicionar:</p>
<ul>
<li><code>id="crono-trigger-hero"</code> no botão ao lado do relógio (<code>MelissaHeroClock.vue</code>)</li>
<li><code>id="crono-trigger-timeline"</code> nos botões overlay (<code>MelissaTimelineHoje.vue</code>)</li>
<li><code>id="crono-dialog"</code> no panel principal (<code>.mc-panel</code>)</li>
<li><code>id="crono-stop-btn"</code> no botão Parar (caminho do salvamento)</li>
<li><code>id="crono-close-btn"</code> no X (caminho do descarte)</li>
</ul>$HTML$,
'Sessão',
true,
'usuario',
'/melissa',
2,
true,
'[{"tipo": "imagem", "url": ""}]'::jsonb
)
RETURNING id INTO v_doc_id;
-- 2) Insere os 12 FAQ items vinculados
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
(v_doc_id, 'Como começo o cronômetro da Larissa que chegou agora pra sessão?',
$FAQ$Quando o horário programado da Larissa estiver dentro da janela do evento ( começou na agenda), aparece um botão <strong></strong> pulsando em verde no canto superior direito do card da sessão na timeline. Clique nele o cronômetro abre com a Larissa pré-selecionada e <strong> começa a contar automaticamente</strong>. Alternativa: clique no botão <em>"Iniciar cronômetro"</em> no card "Próximo paciente" do dashboard (mesmo efeito).$FAQ$, 0, true),
(v_doc_id, 'O cronômetro continua se eu fechar a aba do navegador?',
$FAQ$<strong>Sim.</strong> O estado é salvo no <code>localStorage</code> a cada mudança (paciente, play, pause, ajustes). Ao reabrir a aba (ou recarregar), o cronômetro retoma do ponto correto o tempo passado entre fechar e abrir é descontado automaticamente. Limite: se passar de 24h, o sistema não acumula esse tempo (proteção contra mudanças do relógio do sistema).$FAQ$, 1, true),
(v_doc_id, 'Cliquei no X com sessão rodando, perdi o tempo?',
$FAQ$Não, o sistema <strong>pede confirmação antes</strong>. Quando sessão em andamento ou tempo decorrido sem salvar, aparece um diálogo <em>"Encerrar sessão sem salvar?"</em>. Você precisa clicar em <strong>"Encerrar sem salvar"</strong> (botão vermelho) pra confirmar o descarte. Se o cronômetro estiver limpo (não iniciado, sem tempo), o X fecha direto não nada pra preservar.$FAQ$, 2, true),
(v_doc_id, 'Posso ter dois cronômetros rodando ao mesmo tempo?',
$FAQ$Não. Existe <strong>um cronômetro por vez</strong>. Se você clicar no <strong></strong> de outra sessão enquanto um cronômetro ativo, o sistema mostra um toast <em>"Cronômetro já ativo — sessão de X em andamento"</em> e <strong>não troca</strong>. Pare ou descarte o atual antes de iniciar outro.$FAQ$, 3, true),
(v_doc_id, 'O que significa o badge laranja "atrasada 8 min"?',
$FAQ$Significa que <strong>o cronômetro foi aberto 8 minutos depois do horário programado</strong> da sessão na agenda. Por exemplo: sessão programada pra 11:00, você inicia o cronômetro às 11:08. O badge é apenas informativo o cronômetro continua contando a duração configurada cheia (50min padrão) a partir do clique. Você decide se vai encerrar antes pra terminar no horário previsto ou deixar rodar pra dar a sessão completa.$FAQ$, 4, true),
(v_doc_id, 'O cronômetro desconta o tempo de atraso automaticamente?',
$FAQ$<strong>Não.</strong> A decisão fica com você. Cada clínica e cada terapeuta tem uma política diferente pra atraso (alguns dão sessão cheia, outros encerram no horário programado, outros estendem). O cronômetro mostra a info do atraso pra você decidir, mas conta sempre a duração configurada cheia a partir do clique.$FAQ$, 5, true),
(v_doc_id, 'Que som toca quando o tempo acaba?',
$FAQ$Por padrão, um <strong>som de sino curto</strong>, uma única vez. Você pode trocar em <strong>Configurações Cronômetro Som de término</strong>. Opções: sino, gong, soft, silêncio. O som toca <strong>exatamente na transição</strong> de tempo positivo pra zero/negativo não repete. Depois disso o display continua contando em negativo (vermelho) até você parar.$FAQ$, 6, true),
(v_doc_id, 'Como adiciono mais tempo na sessão sem reiniciar?',
$FAQ$Use os botões <strong>+5 min</strong> e <strong>-5 min</strong> ao redor do display gigante. Funcionam a qualquer momento antes, durante ou depois do tempo acabar. Cada clique soma ou desconta 5 minutos. Se o tempo está negativo (passou do limite), +5min volta a contagem pra positivo.$FAQ$, 7, true),
(v_doc_id, 'Onde fica salvo o tempo final da sessão?',
$FAQ$Quando você clica em <strong> Parar</strong>, o tempo cronometrado é gravado no banco vinculado à sessão da agenda (na tabela <code>agenda_eventos</code>, campo de duração real). Esse caminho é o oficial <strong>fechar pelo X descarta sem salvar</strong>. Sessões com menos de 5 segundos cronometrados são ignoradas (proteção contra start/stop acidentais).$FAQ$, 8, true),
(v_doc_id, 'Posso usar o cronômetro pra coisas que não são sessão de paciente?',
$FAQ$Sim. Selecione <em>"— Atividade livre (sem paciente)"</em> no dropdown de paciente. Útil pra pausa cronometrada, pomodoro pessoal, atendimento informal não cadastrado. Atividade livre <strong>não dispara session-end</strong> ao parar não paciente pra vincular o tempo no DB.$FAQ$, 9, true),
(v_doc_id, 'Como minimizo o cronômetro pra continuar trabalhando?',
$FAQ$Clique no botão <strong>minimizar</strong> no header (ícone <code></code>) ou simplesmente <strong>clique fora do dialog</strong>. O cronômetro vira um chip flutuante no dock (canto inferior esquerdo, ao lado do ψ) e continua contando em background. Pra restaurar: clique no chip. Em mobile, o chip mostra ícone + tempo (sem nome) pra caber no dock estreito.$FAQ$, 10, true),
(v_doc_id, 'Como mudo o paciente no cronômetro já aberto?',
$FAQ$Basta clicar no <strong>select de paciente</strong> e escolher outro. A troca é imediata não reinicia o tempo decorrido (a contagem continua igual). Útil quando você abriu o cronômetro no paciente errado e quer corrigir sem perder o tempo contado. Mas atenção: o <em>session-end</em> ao parar vai vincular o tempo ao paciente que estiver selecionado <em>no momento da parada</em>.$FAQ$, 11, true);
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
END;
$IMPORT$;
COMMIT;
@@ -0,0 +1,173 @@
-- Importacao da doc da aba Documentos do paciente (Fase 2)
-- Gerado a partir de development/saas-docs/03-documentos-paciente-melissa.json
BEGIN;
DO $IMPORT$
DECLARE
v_doc_id uuid;
BEGIN
INSERT INTO public.saas_docs (
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
pagina_path, ordem, ativo, medias
) VALUES (
'Documentos do paciente',
$HTML$<h2>Documentos do paciente</h2>
<p>A aba <strong>Documentos</strong> do prontuário (em <code>/melissa/paciente?id=...&amp;tab=documentos</code>) centraliza tudo que está vinculado àquele paciente: arquivos enviados por upload, documentos gerados a partir de templates (atestados, declarações, recibos, laudos), e tudo que precisa ser compartilhado ou assinado.</p>
<h3>1. Layout 2-col</h3>
<p>A página tem 2 colunas:</p>
<ul>
<li><strong>Sidebar esquerda (~240px):</strong> lista de tipos de documento com contadores. Click num tipo filtra a lista. "Todos" mostra tudo.</li>
<li><strong>Main direita:</strong> grid de cards dos documentos do tipo selecionado, com paginação a partir de 12 itens.</li>
</ul>
<p>No <strong>mobile</strong> (&lt;1024px), a sidebar vira um drawer acessado pelo botão "Tipos" no header.</p>
<h3>2. Toolbar (header)</h3>
<p>3 botões no topo:</p>
<ul>
<li><strong> Atualizar:</strong> refetch da lista (ícone spinner quando carregando)</li>
<li><strong>📄 Gerar:</strong> abre o dialog de geração a partir de template (vide seção 5)</li>
<li><strong> Upload</strong> (botão primário): abre o dialog de envio de arquivo (vide seção 3)</li>
</ul>
<h3>3. Upload de arquivo</h3>
<p>Click no botão <strong>Upload</strong> abre um dialog que aceita:</p>
<ul>
<li><strong>Drag-and-drop</strong> ou seleção manual</li>
<li>Formatos: PDF, imagens (JPG, PNG, WebP), Word, Excel, texto</li>
<li>Metadados opcionais: <strong>tipo</strong>, <strong>categoria</strong>, <strong>descrição</strong>, <strong>tags</strong>, <strong>visibilidade</strong> (privado / compartilhado supervisor / compartilhado portal paciente)</li>
</ul>
<p>Após o upload, o arquivo aparece na lista do tipo escolhido (ou "Outro" se você não selecionou).</p>
<h3>4. Tipos de documento (sidebar)</h3>
<p>Cada documento é classificado em um tipo. Tipos disponíveis:</p>
<ul>
<li><strong>Laudo</strong> laudo psicológico, parecer</li>
<li><strong>Atestado</strong> atestado psicológico</li>
<li><strong>Declaração</strong> comparecimento, início de tratamento, encaminhamento</li>
<li><strong>Recibo</strong> recibos de pagamento gerados</li>
<li><strong>Receita</strong> receituários (uso raro em psicologia)</li>
<li><strong>Exame</strong> laudos/resultados de exames trazidos pelo paciente</li>
<li><strong>Termo assinado</strong> TCLE, autorizações</li>
<li><strong>Relatório externo</strong> relatórios de acompanhamento gerados</li>
<li><strong>Identidade</strong> RG, CPF, CNH (cópias)</li>
<li><strong>Convênio</strong> carteirinhas, autorizações de convênio</li>
<li><strong>Outro</strong> fallback pra tudo que não se encaixa nos tipos acima</li>
</ul>
<p>O contador ao lado de cada tipo mostra quantos docs daquele tipo o paciente tem. Tipos vazios ficam com opacidade reduzida.</p>
<h3>5. Gerar a partir de template</h3>
<p>Click no botão <strong>Gerar</strong> abre o <em>DocumentGenerateDialog</em> em 3 passos:</p>
<ol>
<li><strong>Selecionar template:</strong> grid com todos os templates ativos (globais + do tenant). Click num card seleciona.</li>
<li><strong>Editar variáveis:</strong> os campos do template aparecem com FloatLabel. Variáveis que vêm do sistema (nome do paciente, CRP do terapeuta, CNPJ da clínica etc) vêm preenchidas automaticamente. Banner no topo conta "X de Y preenchidos". Campos vazios mostram um hint embaixo explicando onde cadastrar o dado (ex: <em>"Perfil → Registro Profissional"</em>).</li>
<li><strong>Preview:</strong> iframe sandboxed renderizando o HTML do template com as vars substituídas. Daqui você pode voltar pra editar, baixar o PDF (sem salvar no sistema), ou salvar como documento do paciente.</li>
</ol>
<div style="background: rgba(34,197,94,0.08); border: 1px solid rgba(34,197,94,0.25); border-radius: 10px; padding: 12px 14px; margin: 14px 0; font-size: 0.85rem; color: var(--text-color);">
<strong>💡 Auto-fill cobre:</strong> dados do paciente, terapeuta (incluindo registro profissional formatado tipo "CRP 12345/SP"), clínica/tenant (incluindo CNPJ formatado), data atual em formato curto e por extenso, e se a sessão for vinculada valor da sessão em número e por extenso.
</div>
<h3>6. Editar um documento gerado (re-edição in-place)</h3>
<p>Documentos gerados a partir de template podem ser <strong>re-editados</strong> mantendo o mesmo registro (ID, audit trail e link com o paciente preservados). Click em <strong>Editar</strong> no card do doc ou na sidebar do preview:</p>
<ol>
<li>O sistema busca o template original + os valores que você usou na primeira geração</li>
<li>Abre o dialog em modo edição (header amber "Editar documento") pulando direto pro passo 2 (variáveis pré-preenchidas)</li>
<li>Você ajusta o que precisar Preview <strong>Substituir documento</strong></li>
<li>O PDF é regenerado e substitui o anterior no Storage; o doc fica com o mesmo ID, audit trail intacto</li>
</ol>
<p><strong>Documento legado</strong> (sem registro de geração ou que era um upload): o dialog mostra um toast e cai no fluxo normal de "selecione um template". Ao salvar, ele linka o doc existente ao novo template/valores.</p>
<h3>7. Preview do documento</h3>
<p>Click num card abre o <em>DocumentPreviewDialog</em>:</p>
<ul>
<li><strong>Preview inline:</strong> iframe pra PDF, imagem renderizada direto, fallback "Preview não disponível" pra outros formatos</li>
<li><strong>Sidebar de detalhes</strong> (direita): tipo, categoria, visibilidade, descrição, tags</li>
<li><strong>5 botões de ação</strong> no rodapé da sidebar:
<ul>
<li><strong>Baixar</strong> download direto do arquivo</li>
<li><strong>Editar</strong> abre o generate dialog em modo edição (seção 6)</li>
<li><strong>Compartilhar</strong> gera link compartilhável (seção 8)</li>
<li><strong>Assinar</strong> fluxo de assinatura eletrônica (seção 9)</li>
<li><strong>Excluir</strong> (vermelho) soft-delete com confirmação</li>
</ul>
</li>
</ul>
<h3>8. Compartilhar</h3>
<p>Gera um link público temporário pro paciente acessar o documento sem precisar de login. Configurável:</p>
<ul>
<li>Tempo de expiração (1h, 24h, 7 dias, custom)</li>
<li>Senha opcional</li>
<li>Permitir download ou visualização</li>
</ul>
<p>O status compartilhado fica visível na sidebar de detalhes do preview.</p>
<h3>9. Assinar</h3>
<p>Fluxo de assinatura eletrônica (modal). O documento original recebe uma <strong>página adicional de assinatura</strong> com timestamp e identificação do signatário. A assinatura é registrada em <code>document_signatures</code> com hash do conteúdo original (proof of integrity).</p>
<h3>10. Excluir e recuperar</h3>
<p>Excluir é <strong>soft-delete</strong>: o documento ganha <code>deleted_at</code> mas o arquivo permanece no Storage e o registro fica preservado por <strong>5 anos</strong> (compliance LGPD/CFP). Pra recuperar, em <strong>Configurações Lixo de documentos</strong>.</p>
<h3> Notas pro desenvolvedor</h3>
<p>O componente <code>MelissaPatientDocuments.vue</code> reusa do <code>features/documents</code>:</p>
<ul>
<li><code>useDocuments</code> composable de fetch/CRUD/URLs assinadas</li>
<li><code>DocumentCard</code>, <code>DocumentUploadDialog</code>, <code>DocumentPreviewDialog</code>, <code>DocumentGenerateDialog</code>, <code>DocumentSignatureDialog</code>, <code>DocumentShareDialog</code></li>
</ul>
<p>O linkage <code>document_generated.documento_id</code> (FK pra <code>documents</code>) é o que viabiliza a re-edição in-place. Docs gerados antes da migration de linkage precisam do backfill SQL em <code>database-novo/tmp/backfill-document-generated-link.sql</code>.</p>$HTML$,
'Documentos',
true,
'usuario',
'/melissa/paciente',
3,
true,
'[{"tipo": "imagem", "url": ""}]'::jsonb
)
RETURNING id INTO v_doc_id;
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
(v_doc_id, 'Como envio um documento que já existe (PDF/imagem do paciente)?',
$FAQ$Na aba <strong>Documentos</strong> do prontuário, click no botão <strong>Upload</strong> (azul, no canto superior direito). Você pode arrastar o arquivo pra área do dialog ou clicar pra selecionar. Antes de enviar, preencha o tipo, descrição e tags se quiser assim o doc vai pra categoria certa na sidebar.$FAQ$, 0, true),
(v_doc_id, 'Como gero um documento (atestado, declaração, recibo) a partir de template?',
$FAQ$Click no botão <strong>Gerar</strong> no header da aba Documentos do paciente. O dialog abre em 3 passos: (1) escolha o template, (2) confira as variáveis pré-preenchidas (e ajuste se necessário), (3) preview e <em>Salvar documento</em>. O PDF é gerado e salvo automaticamente no prontuário.$FAQ$, 1, true),
(v_doc_id, 'As variáveis (CRP, nome, CNPJ etc) preenchem sozinhas mesmo?',
$FAQ$Sim, sempre que possível. O sistema busca: dados do paciente (nome, CPF, RG, endereço, telefone, email), do terapeuta (nome, email, telefone, e o registro profissional formatado tipo <em>CRP 12345/SP</em>), da clínica (nome, endereço, telefone, CNPJ formatado), data atual em formato curto e por extenso. Se você abriu o gerador a partir de uma sessão, os dados da sessão (valor, data) também entram. Campos vazios mostram embaixo um hint dizendo onde cadastrar o dado faltante.$FAQ$, 2, true),
(v_doc_id, 'Posso editar um documento gerado sem refazer tudo do zero?',
$FAQ$Sim. Click em <strong>Editar</strong> no card do documento (ou na sidebar do preview). O dialog abre em <em>modo edição</em> com o template original selecionado e <strong>todos os valores que você usou anteriormente preenchidos</strong>. Você ajusta o que precisa, confere o preview e click em <em>Substituir documento</em>. O PDF é regenerado e substitui o anterior, mas o ID e o audit trail do doc continuam os mesmos.$FAQ$, 3, true),
(v_doc_id, 'Posso editar um documento que foi feito por upload (não por template)?',
$FAQ$Sim, mas o fluxo é diferente: como não template original, o sistema mostra um aviso e abre o dialog em modo "selecione um template". Ao salvar, ele <strong>substitui o arquivo enviado por um PDF gerado</strong> e linka ao novo template. Útil pra "converter" um upload manual em algo padronizado. Se você quer trocar o arquivo, exclua o doc e faça upload do novo.$FAQ$, 4, true),
(v_doc_id, 'Como compartilho um documento com o paciente sem ele precisar logar?',
$FAQ$No preview, click em <strong>Compartilhar</strong>. Um dialog gera um link público temporário com opção de tempo de expiração (1h, 24h, 7 dias, custom) e senha opcional. O paciente acessa pelo link, sem login. O status fica visível na sidebar de detalhes do doc.$FAQ$, 5, true),
(v_doc_id, 'Como assino eletronicamente um documento?',
$FAQ$No preview, click em <strong>Assinar</strong>. O fluxo adiciona uma página de assinatura ao PDF com timestamp e identificação. A assinatura é registrada com hash do conteúdo original qualquer alteração posterior invalida a integridade. Ideal pra laudos, declarações e atestados que precisam de validade legal.$FAQ$, 6, true),
(v_doc_id, 'Excluí um documento por engano, dá pra recuperar?',
$FAQ$Sim. Exclusão é <strong>soft-delete</strong> o documento ganha um marcador <code>deleted_at</code> mas continua no banco e o arquivo permanece no Storage. Pra recuperar, em <strong>Configurações Lixo de documentos</strong>. O período de retenção é de <strong>5 anos</strong> (compliance LGPD e regulamentação CFP), depois o arquivo é purgado permanentemente.$FAQ$, 7, true),
(v_doc_id, 'Por que alguns documentos aparecem na categoria "Outro"?',
$FAQ$Documentos enviados por upload sem tipo definido caem em "Outro" automaticamente. Documentos gerados a partir de templates cujo tipo não está mapeado pras categorias padrão (declarações, atestados, laudos, etc) também exemplos: contrato de prestação de serviços, autorização para gravação, termo de consentimento. Você pode mover o doc pra outra categoria editando o tipo na hora do upload ou via menu de ações no card.$FAQ$, 8, true),
(v_doc_id, 'Quais formatos de arquivo posso fazer upload?',
$FAQ$PDF, imagens (JPG, PNG, WebP, GIF), documentos Office (DOCX, XLSX, PPTX), texto simples (TXT, CSV) e formatos compactados (ZIP). Pra qualquer formato fora dessa lista, salve como PDF antes. O preview inline funciona pra PDF e imagens outros formatos mostram a opção "Baixar arquivo" no lugar.$FAQ$, 9, true),
(v_doc_id, 'Como o sistema garante que o documento não vaza pra outros profissionais?',
$FAQ$Cada documento tem um campo de <strong>visibilidade</strong>: <em>Privado</em> ( você ), <em>Compartilhado com supervisor</em> (você + seu supervisor) ou <em>Compartilhado com portal do paciente</em> (o paciente também pelo portal). O default é Privado. RLS (Row Level Security) no banco bloqueia leitura por terceiros, independente da visibilidade. URLs do Storage são assinadas e expiram em 1h.$FAQ$, 10, true),
(v_doc_id, 'Os botões da sidebar do preview (Baixar/Editar/Compartilhar/Assinar/Excluir) não funcionavam, foi corrigido?',
$FAQ$Sim. Bug conhecido até 2026-05-22: o <code>DocumentPreviewDialog</code> emitia os 5 eventos mas o componente pai não os escutava, então nada acontecia ao clicar. Agora todos os 5 botões funcionam normalmente e o de Editar abre o dialog de geração em modo edição.$FAQ$, 11, true);
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
END;
$IMPORT$;
COMMIT;
@@ -0,0 +1,122 @@
-- Importacao da doc da pagina de Templates de documentos (Fase 2)
-- Gerado a partir de development/saas-docs/04-documentos-templates-melissa.json
BEGIN;
DO $IMPORT$
DECLARE
v_doc_id uuid;
BEGIN
INSERT INTO public.saas_docs (
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
pagina_path, ordem, ativo, medias
) VALUES (
'Templates de documentos',
$HTML$<h2>Templates de documentos</h2>
<p>A página <strong>Templates de documentos</strong> (acessível pelo menu Prontuários Templates de documentos, ou diretamente em <code>/melissa/documentos-templates</code>) é onde você gerencia os modelos usados pra gerar atestados, declarações, recibos, laudos e outros documentos clínicos.</p>
<h3>1. Globais vs Tenant (Seus templates)</h3>
<p>A lista é dividida em 2 grupos:</p>
<ul>
<li><strong>Templates padrão (globais)</strong> vêm pré-instalados com o sistema (Declaração de Comparecimento, Atestado Psicológico, Recibo de Pagamento, Laudo Psicológico, Parecer, Encaminhamento, etc). São <strong>read-only</strong> você não pode editar nem desativar, mas pode duplicar pra personalizar.</li>
<li><strong>Seus templates (tenant)</strong> os que você criou ou duplicou. Editáveis, removíveis (desativação soft-delete).</li>
</ul>
<p>Todos os templates ativos do tenant (globais + seus) ficam disponíveis na hora de gerar um documento pro paciente.</p>
<h3>2. Lista de templates</h3>
<p>Cards em grid mostrando: nome, tipo, descrição, badge "padrão" pros globais. No card de cada template do tenant um menu de 3 pontos com: <strong>Duplicar</strong>, <strong>Editar</strong>, <strong>Desativar</strong>. Pros globais, <strong>Duplicar</strong> (e click no card abre a Preview).</p>
<h3>3. Preview de template global (read-only)</h3>
<p>Click num template padrão abre a Preview iframe sandbox renderizando o HTML completo (cabeçalho + corpo + rodapé) com estilos de A4 simulando o PDF final. Header tem botão <strong>Duplicar</strong> pra você levar pros seus templates.</p>
<h3>4. Criar novo template</h3>
<p>Botão <strong>+ Novo template</strong> abre o editor em modo "create". Campos:</p>
<ul>
<li><strong>Nome</strong> e <strong>tipo</strong> (declaração, atestado, recibo, laudo, etc) define a categoria do documento gerado</li>
<li><strong>Descrição</strong> opcional aparece na lista</li>
<li><strong>Cabeçalho</strong> (top fixo) geralmente nome da clínica, endereço, CNPJ</li>
<li><strong>Corpo</strong> (conteúdo principal) o texto do documento com variáveis interpoladas</li>
<li><strong>Rodapé</strong> (bottom fixo) assinatura, contato, observações</li>
</ul>
<h3>5. Editor rich-text + variáveis</h3>
<p>Cada bloco (cabeçalho/corpo/rodapé) tem editor WYSIWYG com formatação, listas, tabelas e inserção de imagens. Ao clicar no botão de <strong>variáveis</strong>, abre um menu com todas as variáveis disponíveis. Click numa insere <code>{{nome_da_variavel}}</code> no cursor.</p>
<div style="background: rgba(99,102,241,0.08); border: 1px solid rgba(99,102,241,0.25); border-radius: 10px; padding: 12px 14px; margin: 14px 0; font-size: 0.85rem; color: var(--text-color);">
<strong>💡 Variáveis disponíveis:</strong> <code>{{paciente_nome}}</code>, <code>{{paciente_cpf}}</code>, <code>{{paciente_rg}}</code>, <code>{{paciente_email}}</code>, <code>{{terapeuta_nome}}</code>, <code>{{terapeuta_registro}}</code> (CRP 12345/SP formatado), <code>{{terapeuta_telefone}}</code>, <code>{{clinica_nome}}</code>, <code>{{clinica_cnpj}}</code>, <code>{{data_atual}}</code>, <code>{{data_atual_extenso}}</code>, e se gerado a partir de sessão <code>{{valor}}</code>, <code>{{valor_extenso}}</code>, <code>{{data_sessao}}</code>. Lista completa no dropdown do editor.
</div>
<h3>6. Mobile (drawer pros templates)</h3>
<p>Em telas &lt;1024px a lista vira um drawer com botão "Templates" no header. Click num item fecha o drawer e mostra o preview/editor ocupando a tela toda.</p>
<h3>7. Duplicar</h3>
<p>Duplicar copia o template (incluindo cabeçalho, corpo, rodapé e variáveis) pra <em>Seus templates</em> com sufixo <em>"(cópia)"</em> no nome. Você edita à vontade depois.</p>
<h3>8. Desativar (soft-delete)</h3>
<p>Templates do tenant podem ser <strong>desativados</strong> (não excluídos). Ficam marcados com <code>ativo = false</code> e somem da lista padrão e do dropdown de geração mas o registro permanece no banco, e documentos antigos gerados a partir desse template continuam acessíveis. Pra reativar, marque "incluir desativados" no filtro (futuro atualmente via DB).</p>
<h3>9. Tipos de template</h3>
<p>Cada template tem um <strong>tipo</strong>. O tipo determina automaticamente qual categoria o documento gerado terá no prontuário do paciente:</p>
<ul>
<li><code>declaracao_comparecimento</code>, <code>declaracao_inicio_tratamento</code>, <code>encaminhamento</code> categoria <strong>Declaração</strong></li>
<li><code>atestado_psicologico</code> categoria <strong>Atestado</strong></li>
<li><code>laudo_psicologico</code>, <code>parecer_psicologico</code> categoria <strong>Laudo</strong></li>
<li><code>recibo_pagamento</code> categoria <strong>Recibo</strong></li>
<li><code>relatorio_acompanhamento</code> categoria <strong>Relatório externo</strong></li>
<li>Outros tipos (<code>termo_consentimento</code>, <code>contrato_servicos</code>, <code>autorizacao_*</code>, <code>outro</code>) categoria <strong>Outro</strong></li>
</ul>
<h3> Notas pro desenvolvedor</h3>
<p>O componente <code>MelissaDocumentosTemplates.vue</code> reusa <code>useDocumentTemplates</code> + <code>DocumentTemplateEditor</code>. A lista de tipos vem do composable (<code>TIPOS_TEMPLATE</code>). O mapeamento tipo de template tipo do documento gerado vive em <code>DocumentGenerate.service.js</code> (<code>TEMPLATE_TYPE_TO_DOC_TYPE</code>). RLS no banco: templates globais (<code>is_global = true</code>) tem leitura aberta; templates do tenant respeitam <code>tenant_id</code>.</p>$HTML$,
'Documentos',
true,
'usuario',
'/melissa/documentos-templates',
4,
true,
'[{"tipo": "imagem", "url": ""}]'::jsonb
)
RETURNING id INTO v_doc_id;
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
(v_doc_id, 'Pra que serve a página de Templates?',
$FAQ$Pra você gerenciar os <strong>modelos</strong> que serão usados na hora de gerar atestados, declarações, recibos, laudos e outros documentos clínicos. Cada template tem cabeçalho, corpo e rodapé com variáveis interpoladas (nome do paciente, CRP, data, etc) quando você usa o botão <em>Gerar</em> num prontuário, é um desses templates que está sendo aplicado.$FAQ$, 0, true),
(v_doc_id, 'Por que não consigo editar os templates padrão (com badge "padrão")?',
$FAQ$Templates marcados como <strong>globais</strong> (badge azul "padrão") vêm pré-instalados com o sistema e são compartilhados entre todos os tenants. Não pra editar pra preservar a versão de referência. Pra personalizar um, click em <strong>Duplicar</strong> uma cópia vai pra <em>Seus templates</em> e ali você edita à vontade.$FAQ$, 1, true),
(v_doc_id, 'Como uso uma variável no template?',
$FAQ$No editor (cabeçalho, corpo ou rodapé), posicione o cursor onde quer a variável e clique no botão de <strong>variáveis</strong> na barra de ferramentas. Um menu lista todas as variáveis disponíveis agrupadas por categoria. Click numa variável insere <code>{{nome_da_variavel}}</code> no cursor. Na hora de gerar o documento, esse placeholder é substituído pelo valor real.$FAQ$, 2, true),
(v_doc_id, 'Quais variáveis estão disponíveis?',
$FAQ$Agrupadas por categoria <strong>Paciente:</strong> nome, CPF, RG, data nascimento, email, telefone, endereço. <strong>Terapeuta:</strong> nome, email, telefone, registro profissional (formatado tipo "CRP 12345/SP"), tipo/número/UF do registro separados. <strong>Clínica:</strong> nome, endereço, telefone, CNPJ. <strong>Sessão:</strong> data, hora, valor, valor por extenso, forma de pagamento, modalidade. <strong>Geral:</strong> data atual, data atual por extenso. Lista completa visível no menu de variáveis do editor.$FAQ$, 3, true),
(v_doc_id, 'Posso recuperar um template que eu desativei?',
$FAQ$Sim, mas hoje via banco de dados (administrador). Desativar é <strong>soft-delete</strong>: o template ganha <code>ativo = false</code> e some da lista. Documentos antigos gerados com ele continuam acessíveis. Em versões futuras teremos um filtro "mostrar desativados" pra reativar via UI.$FAQ$, 4, true),
(v_doc_id, 'Como duplico um template padrão pra personalizar?',
$FAQ$Click no card do template padrão pra abrir a <strong>Preview</strong>. No header da preview tem um botão <strong>Duplicar</strong>. Confirme a cópia aparece em <em>Seus templates</em> com sufixo "(cópia)" no nome. Em seguida click em <strong>Editar</strong> nessa cópia pra ajustar texto, variáveis, cabeçalho, rodapé.$FAQ$, 5, true),
(v_doc_id, 'Qual a diferença prática entre template Global e do Tenant?',
$FAQ$Globais são compartilhados entre todos os tenants (vêm com o sistema) e são <strong>read-only</strong>. Templates do tenant pertencem à sua clínica/conta e são editáveis. Ambos aparecem juntos na hora de gerar um documento você não precisa duplicar pra usar um global, pra personalizar. Se um global atende, use direto.$FAQ$, 6, true),
(v_doc_id, 'Posso usar imagens no template (logo da clínica, assinatura digitalizada)?',
$FAQ$Sim. O editor aceita inserção de imagens via toolbar. Recomendado: PNG ou JPG com tamanho moderado (logo até 200x80px, assinatura até 300x120px). Imagens muito grandes inflam o PDF gerado. Pra incluir o logo da clínica, prefira colocar no <strong>cabeçalho</strong> assim aparece no topo de toda página do PDF.$FAQ$, 7, true),
(v_doc_id, 'O cabeçalho e rodapé aparecem em todas as páginas do PDF?',
$FAQ$Sim. O renderizador usa CSS <code>@page</code> com cabeçalho fixo no topo e rodapé fixo no rodapé de cada página gerada. Documentos curtos (1 página) você não percebe; documentos longos (laudos extensos) repetem cabeçalho/rodapé automaticamente. Útil pra manter identificação da clínica em todas as folhas.$FAQ$, 8, true),
(v_doc_id, 'Como sei se um template tem variável obrigatória?',
$FAQ$Hoje não marcação "obrigatória" todas as variáveis declaradas no template aparecem como editáveis na hora de gerar. Se uma vier vazia (porque não cadastrou no perfil/paciente/etc), o sistema mostra um hint embaixo do campo dizendo onde cadastrar (ex: <em>"Perfil → Registro Profissional"</em>). Você pode gerar mesmo com vazias o placeholder fica como <code>{{variavel}}</code> no PDF, mas isso quase nunca é desejado.$FAQ$, 9, true),
(v_doc_id, 'Tem limite de templates por tenant?',
$FAQ$Não limite hard no banco. Em planos free pode haver limite por contrato (verifique seu plano em Configurações Plano). Recomendado manter o conjunto enxuto (10-20 templates) pra não poluir o dropdown na hora de gerar se você não usa, desative.$FAQ$, 10, true),
(v_doc_id, 'Os templates são compartilhados entre os terapeutas do mesmo tenant?',
$FAQ$Sim. Todos os templates do tenant ficam disponíveis pra todos os usuários ativos do mesmo tenant (clínica). Quem cria/edita pode ser qualquer um com permissão de edição não "templates privados por usuário" no momento. Se precisar isolar templates por terapeuta, organize por nome (ex: "Atestado · Dra. Ana").$FAQ$, 11, true);
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
END;
$IMPORT$;
COMMIT;
@@ -0,0 +1,143 @@
-- Importacao da doc Emissao de recibo profissional (Fase 4 #14)
-- Gerado a partir de development/saas-docs/06-recibo-profissional-melissa.json
BEGIN;
DO $IMPORT$
DECLARE
v_doc_id uuid;
BEGIN
INSERT INTO public.saas_docs (
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
pagina_path, ordem, ativo, medias
) VALUES (
'Emissão de recibo profissional',
$HTML$<h2>Emissão de recibo profissional</h2>
<p>Quando uma sessão é registrada como <strong>paga</strong>, o sistema oferece um botão <em>Emitir recibo</em> que gera um PDF profissional pré-preenchido com todos os dados do paciente, terapeuta, clínica e da sessão sem precisar passar pelo fluxo "Gerar a partir de template" manual.</p>
<h3>1. Quando o botão aparece</h3>
<p>O botão <strong>Emitir recibo</strong> (outlined, ícone PDF) aparece no <em>painel financeiro do evento</em> (<code>AgendaEventoFinanceiroPanel</code>) dentro do modal de uma sessão somente quando:</p>
<ul>
<li>A sessão tem um <strong>financial_record vinculado</strong> (foi gerada cobrança via "Receber")</li>
<li>O status do record é <strong><code>paid</code></strong> (pagamento registrado)</li>
</ul>
<p>Em sessões de pacote (status='contrato'), sem cobrança gerada, pendente, ou cancelada o botão não aparece. Use o fluxo manual de <em>Gerar</em> na aba Documentos pra emitir recibos de casos especiais.</p>
<h3>2. O que o recibo traz preenchido automaticamente</h3>
<ul>
<li><strong>Paciente:</strong> nome, CPF, RG (do cadastro do paciente)</li>
<li><strong>Sessão:</strong> data e hora, modalidade</li>
<li><strong>Valor:</strong> número (R$ 150,00) <strong>e por extenso</strong> ("cento e cinquenta reais")</li>
<li><strong>Forma de pagamento:</strong> PIX, dinheiro, cartão, maquininha, etc vindo do financial_record</li>
<li><strong>Terapeuta:</strong> nome completo + registro profissional formatado ("CRP 12345/SP")</li>
<li><strong>Clínica:</strong> nome, endereço, telefone, CNPJ formatado</li>
<li><strong>Data atual:</strong> em formato curto (22/05/2026) e por extenso ("22 de maio de 2026")</li>
</ul>
<h3>3. Registro profissional genérico</h3>
<p>O sistema suporta <strong>qualquer conselho profissional</strong>, não CRP. A formatação é automática a partir do que está cadastrado no <em>Perfil Registro Profissional</em>:</p>
<ul>
<li><strong>CRP</strong> 12345/SP (psicologia)</li>
<li><strong>CRM</strong> 67890/RJ (medicina)</li>
<li><strong>CRFa</strong> 11111/MG (fonoaudiologia)</li>
<li><strong>CREFITO</strong> 22222/SP (fisioterapia)</li>
<li><strong>CRESS</strong> 33333/RS (serviço social)</li>
<li><strong>CRN</strong> 44444/SP (nutrição)</li>
<li>Ou personalizado via tipo "Outro" + nome livre</li>
</ul>
<p>No template, a variável <code>{{terapeuta_registro}}</code> sempre traz o registro formatado, independente do conselho. Tem também variáveis individuais: <code>{{terapeuta_registro_tipo}}</code>, <code>{{terapeuta_registro_numero}}</code>, <code>{{terapeuta_registro_uf}}</code> pra uso fino.</p>
<h3>4. Valor por extenso</h3>
<p>Helper interno (<code>src/utils/valorExtenso.js</code>) converte número pra extenso em pt-BR completo até 999 milhões:</p>
<div style="background: rgba(99,102,241,0.06); border: 1px solid rgba(99,102,241,0.2); border-radius: 10px; padding: 12px 14px; margin: 12px 0; font-family: 'IBM Plex Mono', monospace; font-size: 0.82rem;">
<strong>R$ 1,00</strong> "um real"<br>
<strong>R$ 150,00</strong> "cento e cinquenta reais"<br>
<strong>R$ 1.234,56</strong> "mil duzentos e trinta e quatro reais e cinquenta e seis centavos"<br>
<strong>R$ 0,50</strong> "cinquenta centavos"<br>
<strong>R$ 1.000.000,00</strong> "um milhão de reais"
</div>
<p>Pluralização correta (real/reais, centavo/centavos), tratamento de centavos isolados ("R$ 0,X"), milhar com "mil" sem "um", milhão/milhões.</p>
<h3>5. Onde o recibo é salvo</h3>
<p>Ao clicar <strong>Emitir recibo</strong>:</p>
<ol>
<li>Sistema busca o template global <code>recibo_pagamento</code></li>
<li>Carrega todas as variáveis (auto-fill descrito acima)</li>
<li>Gera o PDF</li>
<li>Faz upload pro bucket <code>generated-docs</code></li>
<li>Insere registros em <code>documents</code> e <code>document_generated</code> (com linkage)</li>
<li>Dispara <strong>download</strong> automático no browser</li>
<li>Toast "Recibo emitido — PDF baixado e salvo nos documentos do paciente"</li>
</ol>
<p>O recibo aparece na aba <em>Documentos</em> do prontuário do paciente sob a categoria <strong>Recibo</strong>. Pode ser editado in-place, compartilhado ou assinado eletronicamente normalmente.</p>
<h3>6. Quick path vs flow manual</h3>
<p>São <strong>2 caminhos</strong> pra gerar o mesmo PDF:</p>
<ul>
<li><strong>Quick path</strong> (este): clica num botão e pronto. Recibo da sessão paga, valor exato do record, forma de pagamento idem.</li>
<li><strong>Flow manual</strong>: aba Documentos Gerar escolhe template "Recibo de Pagamento" edita valores manualmente preview salva.</li>
</ul>
<p>Use o quick path no fluxo normal. Use o manual quando precisar emitir recibo de algo que não está vinculado a sessão (consulta avulsa) ou quando precisar ajustar valores.</p>
<h3> Notas pro desenvolvedor</h3>
<ul>
<li><strong>Service:</strong> <code>emitirReciboParaSessao(eventoId, { patientId?, valor?, formaPagamento? })</code> em <code>DocumentGenerate.service.js</code>. Quick path one-call: busca template, carrega vars, gera, salva, download.</li>
<li><strong>Helper extenso:</strong> <code>src/utils/valorExtenso.js</code> pt-BR até 999 milhões. Atenção: zero retorna "zero reais", inputs inválidos retornam string vazia.</li>
<li><strong>Mapeamento:</strong> <code>TEMPLATE_TYPE_TO_DOC_TYPE['recibo_pagamento'] = 'recibo'</code> garante que o doc gerado vai pra categoria certa na sidebar.</li>
<li><strong>Template:</strong> migration <code>20260521000008_recibo_uses_terapeuta_registro.sql</code> trocou <code>"Psicólogo(a) - CRP {{terapeuta_crp}}"</code> por <code>{{terapeuta_registro}}</code> no template global. Universal pra qualquer conselho.</li>
<li><strong>Botão UI:</strong> <code>AgendaEventoFinanceiroPanel.vue</code> linha ~320, branch <code>v-else-if="record.status === 'paid'"</code>.</li>
</ul>$HTML$,
'Financeiro',
true,
'usuario',
'/melissa/agenda',
6,
true,
'[{"tipo": "imagem", "url": ""}]'::jsonb
)
RETURNING id INTO v_doc_id;
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
(v_doc_id, 'Como emito um recibo pra uma sessão que recebi o pagamento?',
$FAQ$Abra a sessão no calendário da agenda no painel <em>Cobrança</em> dentro do modal, com o pagamento registrado (status <strong>Pago</strong>), aparece o botão <strong>Emitir recibo</strong>. Clique uma vez. O sistema gera o PDF, salva nos documentos do paciente e dispara o download automaticamente. Toast confirma a operação.$FAQ$, 0, true),
(v_doc_id, 'Por que o botão "Emitir recibo" não aparece na minha sessão?',
$FAQ$O botão aparece quando o financial_record da sessão tem <strong>status = pago</strong>. Possíveis motivos: (1) você não gerou cobrança ainda clique em <em>Receber</em> pra registrar o pagamento primeiro; (2) cobrança está pendente registre o recebimento; (3) sessão é de pacote (status='contrato') pacotes não emitem recibo por sessão, use o fluxo manual em <em>Documentos Gerar</em>; (4) cobrança foi cancelada gere uma nova.$FAQ$, 1, true),
(v_doc_id, 'O valor por extenso vem certo ("cento e cinquenta reais")?',
$FAQ$Sim, com gramática pt-BR correta até 999 milhões. Exemplos: R$ 1,00 "um real", R$ 150,00 "cento e cinquenta reais", R$ 1.234,56 "mil duzentos e trinta e quatro reais e cinquenta e seis centavos", R$ 0,50 "cinquenta centavos". Pluralização real/reais e centavo/centavos automática.$FAQ$, 2, true),
(v_doc_id, 'O recibo funciona pra qualquer conselho profissional (CRM, CRFa…)?',
$FAQ$<strong>Sim.</strong> O template usa a variável <code>{{terapeuta_registro}}</code> que se adapta ao tipo de registro cadastrado no seu Perfil. Funciona pra CRP (psicologia), CRM (medicina), CRFa (fonoaudiologia), CREFITO (fisioterapia), CRESS (serviço social), CRN (nutrição), e qualquer outro conselho incluindo "Outro" com nome livre. A formatação genérica fica tipo "CRP 12345/SP", "CRM 67890/RJ", etc.$FAQ$, 3, true),
(v_doc_id, 'Onde o recibo fica salvo depois de emitido?',
$FAQ$Em <strong>2 lugares</strong>: (1) baixado automaticamente no seu computador via download do navegador; (2) salvo na aba <em>Documentos</em> do prontuário do paciente, na categoria <strong>Recibo</strong> da sidebar. Daí você pode reabrir, compartilhar com o paciente, enviar pra assinar, ou editar in-place se precisar ajustar.$FAQ$, 4, true),
(v_doc_id, 'Posso emitir recibo de algo que não é sessão (consulta avulsa, pacote)?',
$FAQ$Sim, mas pelo <strong>fluxo manual</strong>: na aba <em>Documentos</em> do paciente botão <strong>Gerar</strong> escolha o template <em>"Recibo de Pagamento"</em>. Você preenche os valores na mão (valor, forma de pagamento, descrição) que não vem de uma sessão específica. O resto (CRP, paciente, clínica) auto-completa igual.$FAQ$, 5, true),
(v_doc_id, 'Meu CRP/CRM aparece vazio no recibo, o que fazer?',
$FAQ$Cadastre seu registro profissional em <strong>Perfil Registro Profissional</strong>. Selecione o tipo (CRP/CRM/CRFa//Outro), número e UF. Salve. Próximos recibos gerados trazem formatado. Pra atualizar recibos antigos, abra o doc na aba Documentos do paciente e use <em>Editar</em> o sistema vai puxar o registro atualizado.$FAQ$, 6, true),
(v_doc_id, 'O CNPJ da clínica aparece formatado no recibo?',
$FAQ$Sim, automaticamente. Em <strong>Configurações Negócio (Tenant)</strong>, cadastre o CPF ou CNPJ no campo unificado. O sistema detecta pela quantidade de dígitos: 11 dígitos formata como CPF (XXX.XXX.XXX-XX), 14 como CNPJ (XX.XXX.XXX/XXXX-XX). O recibo usa a variável <code>{{clinica_cnpj}}</code> que sai formatada.$FAQ$, 7, true),
(v_doc_id, 'Errei o valor do recibo, posso corrigir sem gerar outro?',
$FAQ$Sim. na aba <em>Documentos</em> do paciente abra o recibo no preview clique em <strong>Editar</strong>. O dialog abre em modo edição com o template do recibo carregado e os valores anteriores preenchidos. Ajuste o que precisa <em>Substituir documento</em>. O PDF é regenerado e substitui o anterior, mantendo o mesmo ID e audit trail.$FAQ$, 8, true),
(v_doc_id, 'Posso enviar o recibo pro paciente assinar?',
$FAQ$Sim. Recibos são documentos como qualquer outro abra na aba Documentos preview botão <strong>Assinar</strong> na sidebar. Gera link público temporário, paciente abre sem login, marca aceite LGPD, assina. Útil pra recibos de valores altos ou contratos de pacote onde você quer registro formal da concordância.$FAQ$, 9, true),
(v_doc_id, 'Recibo de uma sessão antiga vai com a data de hoje ou a data da sessão?',
$FAQ$<strong>As duas</strong>. O recibo traz a <em>data da sessão</em> ("Referente ao atendimento de 15/03/2026") e a <em>data atual de emissão</em> ("São Carlos, 22 de maio de 2026") no rodapé. Importante pra fiscal a data de emissão indica quando o documento foi formalmente criado, mesmo que a sessão tenha sido meses atrás.$FAQ$, 10, true),
(v_doc_id, 'Posso reemitir um recibo que já foi emitido pra mesma sessão?',
$FAQ$Sim, mas com cuidado. Clicar em <strong>Emitir recibo</strong> de novo gera um <strong>novo PDF</strong> e salva como novo documento na aba você fica com 2 recibos da mesma sessão. Pra apenas atualizar (sem duplicar), edite o existente em <em>Documentos preview Editar</em>. Se duplicar por engano, exclua o antigo (soft-delete preserva por 5 anos no Lixo).$FAQ$, 11, true);
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
END;
$IMPORT$;
COMMIT;
@@ -0,0 +1,165 @@
-- Importacao da doc Relatorios e exportacao (Fase 5 #13)
-- Gerado a partir de development/saas-docs/07-relatorios-export-melissa.json
BEGIN;
DO $IMPORT$
DECLARE
v_doc_id uuid;
BEGIN
INSERT INTO public.saas_docs (
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
pagina_path, ordem, ativo, medias
) VALUES (
'Relatórios de sessões e exportação',
$HTML$<h2>Relatórios de sessões e exportação</h2>
<p>A página <strong>Relatórios</strong> (acessível em <code>/melissa/relatorios</code> ou Prontuários Relatórios) consolida as sessões num período escolhido com KPIs, gráfico de evolução e tabela detalhada. Você pode <strong>exportar tudo pra PDF, Excel ou CSV</strong> respeitando os filtros aplicados.</p>
<h3>1. Layout 2-col</h3>
<ul>
<li><strong>Sidebar esquerda</strong> (~280px): cards de estatísticas clicáveis (atuam como filtros) + seletor de período + filtro por status</li>
<li><strong>Main direita</strong>: gráfico de evolução (Chart.js) + DataTable de sessões filtradas com paginação</li>
</ul>
<p><strong>Mobile</strong> (&lt;1024px): sidebar vira drawer acessado por botão "Filtros" no header.</p>
<h3>2. Filtros de período</h3>
<p>4 opções no seletor:</p>
<ul>
<li><strong>Esta semana</strong> domingo a sábado da semana atual</li>
<li><strong>Este mês</strong> (default) primeiro dia ao último dia do mês corrente</li>
<li><strong>Últimos 3 meses</strong> janela rolante de 3 meses até o fim do mês atual</li>
<li><strong>Últimos 6 meses</strong> idem, janela de 6 meses</li>
</ul>
<p>Ao trocar o período, dispara uma nova query no banco. Os KPIs, gráfico e tabela se atualizam.</p>
<h3>3. Estatísticas (KPIs)</h3>
<p>Sidebar mostra cards com contadores do período:</p>
<ul>
<li><strong>Total de sessões</strong> todas independente de status</li>
<li><strong>Realizadas</strong> concluídas com sucesso</li>
<li><strong>Faltas</strong> paciente faltou</li>
<li><strong>Cancelamentos</strong> sessão cancelada</li>
<li><strong>Remarcadas</strong> paciente remarcou</li>
</ul>
<p>Cada card é <strong>clicável</strong>: filtra a tabela mostrando apenas as sessões daquele status. Clique no mesmo card pra desfazer o filtro.</p>
<h3>4. Gráfico de evolução</h3>
<p>Gráfico de barras/linhas (Chart.js) mostrando a evolução de sessões no período. O agrupamento adapta automaticamente:</p>
<ul>
<li><strong>Semana / Mês</strong> agrupa por <strong>dia</strong></li>
<li><strong>3 meses / 6 meses</strong> agrupa por <strong>semana ISO</strong> ou <strong>mês ISO</strong></li>
</ul>
<p>Cores por status (verde = realizadas, vermelho = faltas, amarelo = canceladas, azul = remarcadas).</p>
<h3>5. Tabela detalhada</h3>
<p>DataTable com colunas: data/hora, paciente, modalidade, status, valor (se aplicável), forma de pagamento. Paginada (15 por página default), ordenável por qualquer coluna. Status com tag colorida.</p>
<h3>6. Exportação 3 formatos</h3>
<p>3 botões no topo da tabela (ou header da página dependendo do layout):</p>
<table>
<thead>
<tr><th>Botão</th><th>Formato</th><th>Quando usar</th></tr>
</thead>
<tbody>
<tr><td><strong>📄 PDF</strong></td><td>PDF A4</td><td>Apresentar pra contador, anexar a processo, arquivo formal com identidade visual da clínica</td></tr>
<tr><td><strong>📊 Excel</strong></td><td>XLSX</td><td>Análise no Excel/Google Sheets, fórmulas, gráficos próprios, manipulação fina</td></tr>
<tr><td><strong>📋 CSV</strong></td><td>CSV UTF-8</td><td>Importar em outro sistema, processamento via script, BI externo</td></tr>
</tbody>
</table>
<div style="background: rgba(34,197,94,0.06); border: 1px solid rgba(34,197,94,0.25); border-radius: 10px; padding: 12px 14px; margin: 14px 0; font-size: 0.85rem;">
<strong>🎯 Os filtros aplicados na tela são respeitados</strong> se você filtrou por "Realizadas" e exportou pra Excel, as realizadas vão pro arquivo. Período idem. Quer todos os status? clique no card de filtro pra desfazer antes de exportar.
</div>
<h3>7. Detalhes técnicos por formato</h3>
<h4>PDF</h4>
<ul>
<li>Renderizado client-side via HTML PDF (mesmo pipeline do gerador de documentos)</li>
<li>Cabeçalho com KPIs em destaque + tabela A4 abaixo</li>
<li>Identidade visual: nome da clínica, logo (se cadastrado), data de geração</li>
<li>Tamanho: 1 página por ~30 sessões; relatórios longos paginam automaticamente com cabeçalho/rodapé fixos</li>
</ul>
<h4>Excel (XLSX)</h4>
<ul>
<li>Gerado com <code>exceljs</code> (import dinâmico não infla o bundle inicial)</li>
<li><strong>Frozen header</strong> primeira linha fica fixa ao rolar</li>
<li><strong>Alternating rows</strong> zebrado pra leitura</li>
<li>Colunas formatadas: data como data, valor como currency BRL</li>
<li>Branded cabeçalho com cor da clínica</li>
</ul>
<h4>CSV</h4>
<ul>
<li>Vanilla JS sem dependência externa, gerado instantaneamente</li>
<li><strong>BOM UTF-8</strong> no início força Excel a abrir com acentos corretos</li>
<li><strong>Separador <code>;</code></strong> (padrão pt-BR Excel BR espera ; em vez de ,)</li>
<li>Aspas em campos com vírgula ou quebra de linha</li>
</ul>
<h3>8. Nome do arquivo gerado</h3>
<p>Padrão <code>relatorio_sessoes_AAAAMMDD_HHmm.{pdf|xlsx|csv}</code> com timestamp da hora de geração. Garante que múltiplas exportações no mesmo dia não sobrescrevem.</p>
<h3> Notas pro desenvolvedor</h3>
<ul>
<li><strong>Service:</strong> <code>src/services/reportExport.service.js</code> com 3 funções: <code>exportSessionsToPDF</code>, <code>exportSessionsToXLSX</code>, <code>exportSessionsToCSV</code>. Todas aceitam <code>{ sessions, period, statusFilter, tenant }</code>.</li>
<li><strong>PDF:</strong> usa <code>pdf.service.htmlToPdfBlob</code> (mesmo do gerador de documentos)</li>
<li><strong>XLSX:</strong> <code>const { default: ExcelJS } = await import('exceljs')</code> code splitting</li>
<li><strong>CSV:</strong> vanilla JS com BOM + escape de campos</li>
<li><strong>Pages:</strong> <code>RelatoriosPage.vue</code> (rota classic/Rail) e <code>MelissaRelatorios.vue</code> (rota Melissa) compartilham o mesmo service</li>
<li><strong>Pendência:</strong> exportação agendada (envio automático por email no dia 1 de cada mês) depende do Módulo 6 notifications. Hoje on-demand.</li>
</ul>$HTML$,
'Relatórios',
true,
'usuario',
'/melissa/relatorios',
7,
true,
'[{"tipo": "imagem", "url": ""}]'::jsonb
)
RETURNING id INTO v_doc_id;
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
(v_doc_id, 'Como vejo um resumo das minhas sessões num período?',
$FAQ$Abra a página <strong>Relatórios</strong> (menu Prontuários Relatórios, ou diretamente em <code>/melissa/relatorios</code>). Você escolhe o período na sidebar esquerda (esta semana, este mês, últimos 3 ou 6 meses) e o sistema mostra KPIs em cards, gráfico de evolução e tabela detalhada de cada sessão.$FAQ$, 0, true),
(v_doc_id, 'Quais períodos posso filtrar?',
$FAQ$4 opções fixas no seletor: <strong>Esta semana</strong> (domingo a sábado da semana corrente), <strong>Este mês</strong> (default dia 1 ao último dia do mês atual), <strong>Últimos 3 meses</strong> e <strong>Últimos 6 meses</strong> (janelas rolantes terminando no fim do mês atual). Não custom date range na UI ainda pra filtrar uma data específica, exporte pra Excel ou CSV e filtre .$FAQ$, 1, true),
(v_doc_id, 'Como exporto o relatório pra PDF?',
$FAQ$No topo da página de Relatórios, clique no botão <strong>PDF</strong> (ícone vermelho de arquivo). O sistema renderiza o relatório com KPIs em destaque + tabela A4 e dispara o download. Útil pra apresentar pra contador, anexar a processos ou arquivar formalmente. O PDF traz a identidade visual da clínica (nome, logo se cadastrado, data de geração).$FAQ$, 2, true),
(v_doc_id, 'Como exporto pra Excel?',
$FAQ$Botão <strong>Excel</strong> (ícone verde) no topo da página. Gera um arquivo <code>.xlsx</code> com cabeçalho fixo (frozen header), linhas zebradas pra leitura, colunas formatadas (datas como data, valores como moeda BRL) e cabeçalho com cor da clínica. Pronto pra análise no Excel, Google Sheets ou LibreOffice.$FAQ$, 3, true),
(v_doc_id, 'Quando devo usar CSV em vez de Excel?',
$FAQ$Use <strong>CSV</strong> quando precisar importar os dados em outro sistema (ERP, BI, banco de dados), fazer processamento via script, ou compartilhar com alguém que não tenha Excel. O arquivo é mais leve e universal. Use <strong>Excel</strong> quando o destino final for análise humana formatação de moeda, gráficos próprios, fórmulas. Os 2 trazem os mesmos dados.$FAQ$, 4, true),
(v_doc_id, 'Os filtros aplicados na tela também valem pra exportação?',
$FAQ$<strong>Sim, sempre.</strong> Se você filtrou por "Realizadas" clicando no card de KPI, as sessões realizadas vão pro arquivo exportado. Período idem. Quer exportar todos os status? clique no card de filtro pra desfazer (ou clique em outro KPI e depois nele de novo) antes de exportar. Na dúvida, o título do PDF/Excel sempre traz os filtros aplicados na primeira linha.$FAQ$, 5, true),
(v_doc_id, 'O Excel exportado tem fórmulas ou só dados?',
$FAQ$Só dados. As colunas vêm formatadas (data como data, valor como moeda BRL) mas sem fórmulas pré-instaladas você adiciona o que precisar depois (somas, médias, gráficos). Decisão de design: pra evitar conflito com diferentes locales/versões do Excel, exportamos puro e você customiza.$FAQ$, 6, true),
(v_doc_id, 'Por que o gráfico às vezes mostra dias e às vezes semanas/meses?',
$FAQ$Agrupamento automático conforme o período pra evitar gráfico ilegível: <strong>Semana / Mês</strong> 7-31 colunas por dia (legível). <strong>3 meses</strong> ~13 colunas por semana ISO. <strong>6 meses</strong> ~26 colunas ou ~6 colunas por mês ISO. Se forçássemos 180 colunas em "6 meses", ficaria ilegível.$FAQ$, 7, true),
(v_doc_id, 'Posso filtrar o relatório por um paciente específico?',
$FAQ$Hoje não diretamente na página de Relatórios. Pra ver sessões de um paciente específico, no <strong>prontuário do paciente</strong> (aba Sessões) tem timeline completa com filtros próprios. Ou exporte o relatório geral pra Excel/CSV e filtre por nome do paciente no Excel.$FAQ$, 8, true),
(v_doc_id, 'Consigo ver o relatório de outro terapeuta da clínica?',
$FAQ$Depende da sua permissão no tenant. Por default, cada terapeuta as próprias sessões. Owners/admins do tenant podem ter acesso aos relatórios consolidados de todos os profissionais verifique em <strong>Configurações Equipe</strong> qual é seu papel. Pra solicitar acesso ampliado, fale com o owner do tenant.$FAQ$, 9, true),
(v_doc_id, 'Como ficam os nomes dos arquivos exportados?',
$FAQ$Padrão <code>relatorio_sessoes_AAAAMMDD_HHmm.{pdf|xlsx|csv}</code>. Exemplo: <code>relatorio_sessoes_20260522_1430.xlsx</code>. Timestamp garante que múltiplas exportações no mesmo dia não sobrescrevem o anterior fica fácil organizar versões.$FAQ$, 10, true),
(v_doc_id, 'Posso agendar exportações automáticas (envio por email mensal)?',
$FAQ$Ainda não. Hoje a exportação é <strong>on-demand</strong> você precisa abrir a página e clicar no botão. Exportação agendada (ex: PDF mensal enviado por email no dia 1) está no roadmap pós-MVP, depende do Módulo 6 (notifications factory channel) que ainda não foi implementado. Por enquanto, agende um lembrete pra você abrir a página todo dia 1.$FAQ$, 11, true);
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
END;
$IMPORT$;
COMMIT;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+19 -1
View File
@@ -15,7 +15,7 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed } from 'vue';
import { ref, computed, watch, onBeforeUnmount } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAjuda } from '@/composables/useAjuda';
@@ -73,6 +73,24 @@ function fechar() {
faqAbertos.value = {};
closeDrawer();
}
// ── Fechar ao clicar fora ─────────────────────────────────────
// Listener so existe enquanto o drawer esta aberto. Clique nos botoes
// que abrem/fecham o drawer (marcados com data-ajuda-toggle) sao
// ignorados — senao fecha aqui e o @click reabre.
function onDocMouseDown(e) {
if (!drawerOpen.value) return;
const t = e.target;
if (!(t instanceof Element)) return;
if (t.closest('.ajuda-panel')) return; // dentro do drawer
if (t.closest('[data-ajuda-toggle]')) return; // botao trigger
closeDrawer();
}
watch(drawerOpen, (open) => {
if (open) document.addEventListener('mousedown', onDocMouseDown, true);
else document.removeEventListener('mousedown', onDocMouseDown, true);
});
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocMouseDown, true));
// ── Highlight de elemento na página ──────────────────────────
async function handleDocClick(e) {
const anchor = e.target.closest('a[data-highlight]');
+45 -9
View File
@@ -17,31 +17,61 @@
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
const isOnline = ref(true); // começa como true; detecta em onMounted
// ── Estado ────────────────────────────────────────────────────
// Começa otimista (true) — só marca offline com confirmação dupla.
const isOnline = ref(true);
const wasOffline = ref(false);
const showReconnected = ref(false);
let pollTimer = null;
let reconnectedTimer = null;
let consecutiveFailures = 0;
// ── Detecção real: tenta buscar um recurso minúsculo ──────────
// Em DEV, ignora completamente o polling: Vite HMR + dev server podem
// disparar falhas pontuais que geram falso positivo constante. Em DEV,
// só confia em navigator.onLine + eventos nativos (mais conservador).
const IS_DEV = import.meta.env?.DEV === true;
// Tolerância: precisa N falhas seguidas pra considerar offline. Evita
// falso positivo de slow request / HMR rebuild / network blip.
const FAILURE_THRESHOLD = 2;
const POLL_INTERVAL = IS_DEV ? 60_000 : 30_000;
const FETCH_TIMEOUT = 8_000;
// ── Detecção: navigator.onLine primeiro, fetch como confirmação ──
//
// navigator.onLine é a fonte autoritativa do browser. Se for true,
// quase certo que tem rede física. Se for false, com certeza offline.
// O fetch só serve pra detectar "rede funciona mas servidor parado".
async function checkConnectivity() {
// 1) Browser offline = confia direto, sem fetch
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
consecutiveFailures = FAILURE_THRESHOLD;
setOffline();
return;
}
// 2) Browser online — confirma com HEAD no favicon (rápido, cacheável)
try {
// favicon do próprio app (cache busted) — não depende de rede externa
await fetch('/favicon.ico?_t=' + Date.now(), {
method: 'HEAD',
cache: 'no-store',
signal: AbortSignal.timeout(4000)
signal: AbortSignal.timeout(FETCH_TIMEOUT)
});
consecutiveFailures = 0;
setOnline();
} catch {
setOffline();
consecutiveFailures++;
// Só marca offline após N falhas consecutivas — evita falso positivo
// de slow request, HMR rebuild, transient blip.
if (consecutiveFailures >= FAILURE_THRESHOLD) {
setOffline();
}
}
}
function setOnline() {
if (!isOnline.value && wasOffline.value) {
// acabou de reconectar
showReconnected.value = true;
if (reconnectedTimer) clearTimeout(reconnectedTimer);
reconnectedTimer = setTimeout(() => {
@@ -59,19 +89,25 @@ function setOffline() {
}
// ── Eventos nativos do browser ────────────────────────────────
// navigator.onLine + offline/online events são SUPER confiáveis pra
// estado real (sem rede física, wifi caiu, etc). Outros falsos
// positivos vinham só do fetch agressivo.
function onBrowserOffline() {
consecutiveFailures = FAILURE_THRESHOLD;
setOffline();
}
function onBrowserOnline() {
consecutiveFailures = 0;
checkConnectivity();
} // confirma antes de marcar online
}
onMounted(() => {
window.addEventListener('offline', onBrowserOffline);
window.addEventListener('online', onBrowserOnline);
// Polling a cada 10 s — captura quedas que não disparam evento
pollTimer = setInterval(checkConnectivity, 10_000);
// Polling defensivo — captura quedas que não disparam evento
// (raras, ex: DNS travado em wifi público).
pollTimer = setInterval(checkConnectivity, POLL_INTERVAL);
// Verifica estado atual ao montar (útil se já começou offline)
checkConnectivity();
+16 -4
View File
@@ -197,8 +197,17 @@ function generateUser() {
});
}
function patientsListRoute() {
// Rota de destino do "Salvar e ver paciente". Em melissa, prefere a
// view individual do paciente recém-criado (id vem de data.id no
// emit('created')); fallback pra lista.
function patientViewRoute(patientId) {
const p = String(route.path || '');
if (p.startsWith('/melissa') && patientId) {
return { path: '/melissa/paciente', query: { id: String(patientId) } };
}
if (p.startsWith('/melissa')) {
return '/melissa/pacientes';
}
return p.startsWith('/therapist') ? '/therapist/patients' : '/admin/pacientes';
}
@@ -252,7 +261,10 @@ async function submit(mode = 'only') {
emit('created', data);
if (props.closeOnCreated) close();
if (mode === 'view') await router.push(patientsListRoute());
if (mode === 'view') {
const pid = data?.id || null;
await router.push(patientViewRoute(pid));
}
} catch (err) {
const msg = err?.message || err?.details || 'Não foi possível criar o paciente.';
errorMsg.value = msg;
@@ -334,10 +346,10 @@ async function submit(mode = 'only') {
<!-- Na rota de pacientes OU em fluxo (hideViewListButton): "Salvar" / "Salvar e fechar" -->
<Button v-if="isOnPatientsPage" label="Salvar" :loading="saving" :disabled="saving" @click="submit('only')" />
<Button v-else-if="hideViewListButton" label="Salvar e fechar" :loading="saving" :disabled="saving" @click="submit('only')" />
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver pacientes" -->
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver paciente" -->
<template v-else>
<Button label="Salvar e fechar" severity="secondary" outlined :loading="saving" :disabled="saving" @click="submit('only')" />
<Button label="Salvar e ver pacientes" :loading="saving" :disabled="saving" @click="submit('view')" />
<Button label="Salvar e ver paciente" :loading="saving" :disabled="saving" @click="submit('view')" />
</template>
</div>
</template>
+1 -1
View File
@@ -34,7 +34,7 @@ export const PAGES = [
{ id: 'p_t_medicos', label: 'Médicos referenciadores', icon: 'pi pi-user-edit', sublabel: 'Médicos que encaminham pacientes', path: '/therapist/patients/medicos', roles: ['therapist'], keywords: kw('medicos','encaminhadores','referenciadores','indicacao') },
{ id: 'p_t_link_externo', label: 'Link de cadastro externo', icon: 'pi pi-link', sublabel: 'Link público pra pacientes', path: '/therapist/patients/link-externo', roles: ['therapist'], keywords: kw('link','externo','publico','cadastro paciente','convite') },
{ id: 'p_t_cad_recebidos', label: 'Cadastros recebidos', icon: 'pi pi-inbox', sublabel: 'Pacientes aguardando aceite', path: '/therapist/patients/cadastro/recebidos', roles: ['therapist'], keywords: kw('recebidos','pendentes','aceitar','intake','novos') },
{ id: 'p_t_doc_templates', label: 'Templates de documentos', icon: 'pi pi-file-edit', sublabel: 'Modelos reutilizáveis', path: '/therapist/documents/templates', roles: ['therapist'], keywords: kw('templates','modelos','contratos','documentos') },
{ id: 'p_cfg_doc_templates', label: 'Modelos de documentos', icon: 'pi pi-file-edit', sublabel: 'Configurações → Documentos', path: '/configuracoes/documentos/templates', roles: ['therapist','admin'], keywords: kw('templates','modelos','contratos','documentos','recibo','atestado','laudo','tcle','lgpd','consent') },
{ id: 'p_t_online_sched', label: 'Agendamento online', icon: 'pi pi-globe', sublabel: 'Página pública de agendamento', path: '/therapist/online-scheduling', roles: ['therapist'], keywords: kw('online','publico','agendar','landing','pagina','site') },
{ id: 'p_t_ag_recebidos', label: 'Agendamentos recebidos', icon: 'pi pi-calendar-plus',sublabel: 'Solicitações da agenda pública', path: '/therapist/agendamentos-recebidos', roles: ['therapist'], keywords: kw('solicitacoes','recebidos','publico','pedidos') },
{ id: 'p_t_fin_lanc', label: 'Lançamentos financeiros', icon: 'pi pi-list', sublabel: 'Entradas e saídas', path: '/therapist/financeiro/lancamentos', roles: ['therapist'], keywords: kw('lancamentos','entradas','saidas','fluxo de caixa','receitas','despesas') },
+21 -4
View File
@@ -117,14 +117,14 @@ function buildConfig() {
];
// Toolbar completa para o corpo do e-mail
// Botões hr (linha horizontal), eraser (apagar formatação) e source (HTML)
// foram removidos — não funcionavam de forma esperada.
const bodyButtons = [
'bold', 'italic', 'underline', 'strikethrough', '|',
'ul', 'ol', '|',
'font', 'fontsize', 'brush', 'paragraph', '|',
'align', '|',
'link', 'table', '|',
'hr', 'eraser', '|',
'source'
'link', 'table'
];
return {
@@ -194,7 +194,24 @@ watch(
// ── API exposta ───────────────────────────────────────────────
defineExpose({
insertHTML: (html) => jodit?.selection.insertHTML(html)
insertHTML: (html) => jodit?.selection.insertHTML(html),
// Salva markers da seleção atual antes do foco sair do editor
// (ex: usuário abre drawer e perde o cursor). Retorna o array de
// markers que pode ser passado pra restoreSelection depois.
saveSelection: () => {
if (!jodit) return null;
try { return jodit.selection.save(); }
catch { return null; }
},
// Restaura selection a partir dos markers salvos. Re-foca o editor.
restoreSelection: (markers) => {
if (!jodit) return;
try {
jodit.focus();
if (markers) jodit.selection.restore(markers);
} catch { /* silencioso */ }
},
focus: () => jodit?.focus()
});
</script>
+18 -5
View File
@@ -65,11 +65,22 @@ const router = useRouter();
const isOnPatientsPage = computed(() => {
const p = String(route.path || '');
return p.includes('/patients') || p.includes('/pacientes');
// /melissa/paciente (singular — prontuário) é página de paciente.
// /melissa/pacientes (plural — lista) também.
return p.includes('/patients') || p.includes('/pacientes') || p.startsWith('/melissa/paciente');
});
function patientsListRoute() {
// Rota de destino quando o usuário pede "Salvar e ver paciente":
// — no Melissa, abre o prontuário do paciente (singular, via query id)
// — no Therapist/Admin, volta pra lista (não há rota dedicada de view).
function patientViewRoute(patientId) {
const p = String(route.path || '');
if (p.startsWith('/melissa') && patientId) {
return { path: '/melissa/paciente', query: { id: String(patientId) } };
}
if (p.startsWith('/melissa')) {
return '/melissa/pacientes';
}
return p.startsWith('/therapist') ? '/therapist/patients' : '/admin/pacientes';
}
@@ -82,7 +93,9 @@ async function onCreated(data) {
isOpen.value = false;
emit('created', data);
if (pendingMode.value === 'view') {
await router.push(patientsListRoute());
// data.id vem do PatientsCadastroPage (criação ou edição)
const pid = data?.id || props.patientId || null;
await router.push(patientViewRoute(pid));
}
}
</script>
@@ -197,10 +210,10 @@ async function onCreated(data) {
<!-- Na rota de pacientes OU em fluxo (hideViewListButton): um botao -->
<Button v-if="isOnPatientsPage" label="Salvar" :loading="!!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
<Button v-else-if="hideViewListButton" label="Salvar e fechar" :loading="!!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver pacientes" -->
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver paciente" -->
<template v-else>
<Button label="Salvar e fechar" severity="secondary" outlined :loading="pendingMode === 'only' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
<Button label="Salvar e ver pacientes" :loading="pendingMode === 'view' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('view')" />
<Button label="Salvar e ver paciente" :loading="pendingMode === 'view' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('view')" />
</template>
</div>
</template>
@@ -13,12 +13,19 @@ import { ref, watch, computed } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useDocumentGenerate } from '../composables/useDocumentGenerate'
import { useDocumentTemplates } from '../composables/useDocumentTemplates'
import { loadGeneratedFromDocId } from '@/services/DocumentGenerate.service'
const props = defineProps({
visible: { type: Boolean, default: false },
patientId: { type: String, default: null },
patientName: { type: String, default: '' },
agendaEventoId: { type: String, default: null }
agendaEventoId: { type: String, default: null },
// Modo edicao: ID de um documents.id existente. Quando setado, o dialog
// busca o template_id + dados_preenchidos do document_generated vinculado,
// pre-seleciona o template e popula as variaveis. Save vira UPDATE
// in-place (preserva documents.id e audit). Doc sem registro generated
// (uploaded direto) cai no flow normal de "select template".
editingDocId: { type: String, default: null }
})
const emit = defineEmits(['update:visible', 'generated'])
@@ -52,13 +59,48 @@ const {
// ── Reset ao abrir ──────────────────────────────────────────
watch(() => props.visible, async (v) => {
if (v) {
step.value = 'select'
reset()
await Promise.all([
fetchTemplates(),
props.patientId ? loadVariables(props.patientId, props.agendaEventoId) : Promise.resolve()
])
if (!v) return;
step.value = 'select'
reset()
await Promise.all([
fetchTemplates(),
props.patientId ? loadVariables(props.patientId, props.agendaEventoId) : Promise.resolve()
])
// Modo edicao: tenta carregar o registro do generated, pre-seleciona
// template e popula vars com dados_preenchidos (sobrescreve auto-vars
// — preserva customizacao anterior do user). Se nao houver linkage
// (doc uploaded direto), continua no flow normal de "select template".
if (props.editingDocId) {
const gen = await loadGeneratedFromDocId(props.editingDocId)
if (gen?.template_id) {
try {
await selectTemplate(gen.template_id)
// Merge: dados_preenchidos override auto-loaded variables.
// Mantemos as vars que o user nao tinha customizado da vez
// anterior (pra caso o template tenha vars novas adicionadas
// depois) — pegamos as keys auto + sobrescreve com generated.
const saved = gen.dados_preenchidos || {}
Object.entries(saved).forEach(([k, val]) => {
setVariable(k, val == null ? '' : String(val))
})
step.value = 'edit'
} catch (e) {
toast.add({
severity: 'warn',
summary: 'Template original não encontrado',
detail: 'Selecione um template para regenerar o documento.',
life: 3500
})
}
} else {
toast.add({
severity: 'info',
summary: 'Documento legado',
detail: 'Sem dados de edição. Selecione um template para regenerar.',
life: 3500
})
}
}
})
@@ -79,11 +121,19 @@ const editableVars = computed(() => {
key,
label: meta?.label || key,
grupo: meta?.grupo || 'Outros',
source: meta?.source || '',
value: variables.value[key] || ''
}
})
})
// Estatística pro topo: quantos campos vieram do auto-fill vs vazios
const varStats = computed(() => {
const total = editableVars.value.length
const filled = editableVars.value.filter(v => String(variables.value[v.key] || '').trim() !== '').length
return { total, filled, empty: total - filled }
})
const varGroups = computed(() => {
const groups = {}
for (const v of editableVars.value) {
@@ -101,8 +151,15 @@ function onVarChange(key, val) {
async function onGenerate() {
try {
const result = await generateAndSave(props.patientId)
toast.add({ severity: 'success', summary: 'Documento salvo', detail: 'Disponível nos documentos do paciente.', life: 3000 })
const result = await generateAndSave(props.patientId, props.editingDocId || null)
toast.add({
severity: 'success',
summary: props.editingDocId ? 'Documento atualizado' : 'Documento salvo',
detail: props.editingDocId
? 'PDF substituído com os novos valores.'
: 'Disponível nos documentos do paciente.',
life: 3000
})
emit('generated', result)
close()
} catch (e) {
@@ -145,10 +202,10 @@ function close() {
<template #header>
<div class="flex items-center gap-3">
<span class="flex items-center justify-center w-8 h-8 rounded-lg bg-green-500/10">
<i class="pi pi-file-pdf text-green-600" />
<i :class="editingDocId ? 'pi pi-pencil text-amber-600' : 'pi pi-file-pdf text-green-600'" />
</span>
<div>
<div class="text-base font-semibold">Gerar documento</div>
<div class="text-base font-semibold">{{ editingDocId ? 'Editar documento' : 'Gerar documento' }}</div>
<div class="text-xs text-[var(--text-color-secondary)]">
<template v-if="step === 'select'">Selecione um template</template>
<template v-else-if="step === 'edit'">{{ selectedTemplate?.nome_template }} {{ patientName }}</template>
@@ -192,17 +249,54 @@ function close() {
</div>
<!-- Step 2: Editar variaveis -->
<div v-else-if="step === 'edit'" class="flex flex-col gap-4">
<div v-else-if="step === 'edit'" class="flex flex-col gap-5">
<!-- Resumo do preenchimento automático -->
<div
v-if="varStats.total"
class="flex items-center gap-2 text-xs px-3 py-2 rounded-lg"
:class="varStats.empty === 0
? 'bg-green-500/10 text-green-700 dark:text-green-400'
: 'bg-amber-500/10 text-amber-700 dark:text-amber-400'"
>
<i :class="varStats.empty === 0 ? 'pi pi-check-circle' : 'pi pi-info-circle'" />
<span v-if="varStats.empty === 0">
Todos os {{ varStats.total }} campos foram preenchidos automaticamente.
</span>
<span v-else>
{{ varStats.filled }} de {{ varStats.total }} preenchidos. Os campos vazios mostram onde cadastrar o dado.
</span>
</div>
<!-- Erro de carregamento de variáveis -->
<div
v-if="genError"
class="flex items-center gap-2 text-xs px-3 py-2 rounded-lg bg-red-500/10 text-red-600"
>
<i class="pi pi-exclamation-circle" />
<span>{{ genError }}</span>
</div>
<div v-for="(vars, grupo) in varGroups" :key="grupo">
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-2">{{ grupo }}</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-3">{{ grupo }}</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div v-for="v in vars" :key="v.key" class="flex flex-col gap-1">
<label class="text-xs text-[var(--text-color-secondary)]">{{ v.label }}</label>
<InputText
:modelValue="variables[v.key] || ''"
@update:modelValue="onVarChange(v.key, $event)"
class="w-full"
/>
<FloatLabel variant="on">
<InputText
:id="`docgen-var-${v.key}`"
:modelValue="variables[v.key] || ''"
@update:modelValue="onVarChange(v.key, $event)"
class="w-full"
:invalid="!String(variables[v.key] || '').trim()"
/>
<label :for="`docgen-var-${v.key}`">{{ v.label }}</label>
</FloatLabel>
<small
v-if="!String(variables[v.key] || '').trim() && v.source"
class="text-[0.65rem] text-[var(--text-color-secondary)] flex items-center gap-1 ml-1"
>
<i class="pi pi-link text-[0.55rem]" />
{{ v.source }}
</small>
</div>
</div>
</div>
@@ -254,7 +348,7 @@ function close() {
/>
<Button
v-if="step === 'preview'"
label="Salvar documento"
:label="editingDocId ? 'Substituir documento' : 'Salvar documento'"
icon="pi pi-check"
@click="onGenerate"
:loading="generating"
@@ -9,7 +9,7 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, watch, computed } from 'vue'
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
import { useDocumentTemplates } from '../composables/useDocumentTemplates'
import JoditEmailEditor from '@/components/ui/JoditEmailEditor.vue'
@@ -23,6 +23,8 @@ const emit = defineEmits(['update:modelValue', 'save', 'cancel'])
const { TIPOS_TEMPLATE, TEMPLATE_VARIABLES, variablesGrouped, previewHtml } = useDocumentTemplates()
const activeTab = ref('editor') // editor | preview
// Sub-tab do editor (centro do layout 3-col): qual seção renderiza
const editorTab = ref('corpo') // cabecalho | corpo | rodape
// ── Form reativo synced com modelValue ──────────────────────
@@ -70,13 +72,43 @@ function insertVariable(varKey) {
rodape_html: editorRodape
}
const editorRef = editorMap[cursorField.value]
// No mobile: fecha drawer + defere insertHTML pós-transição.
// Restaura a selection capturada quando o drawer abriu (cursor
// original do usuário) antes de inserir → variável aparece no
// ponto certo do texto, não no final.
if (isMobile.value) {
drawerOpen.value = false;
const markers = savedSelection.value;
setTimeout(() => {
try {
if (markers && editorRef?.value?.restoreSelection) {
editorRef.value.restoreSelection(markers);
}
if (editorRef?.value?.insertHTML) {
editorRef.value.insertHTML(tag);
} else {
// Fallback se a API expose falhar
form.value[cursorField.value] = (form.value[cursorField.value] || '') + tag;
}
} catch {
form.value[cursorField.value] = (form.value[cursorField.value] || '') + tag;
}
if (!form.value.variaveis.includes(varKey)) {
form.value.variaveis = [...form.value.variaveis, varKey];
}
savedSelection.value = null;
}, 280);
return;
}
// Desktop: insertHTML mantém posição do cursor (foco já tá no editor)
if (editorRef?.value?.insertHTML) {
editorRef.value.insertHTML(tag)
} else {
form.value[cursorField.value] = (form.value[cursorField.value] || '') + tag
}
// Adiciona a variavel na lista se nao estiver
if (!form.value.variaveis.includes(varKey)) {
form.value.variaveis = [...form.value.variaveis, varKey]
}
@@ -87,129 +119,756 @@ function insertVariable(varKey) {
function onSave() {
emit('save', { ...form.value })
}
// ── Mobile drawer (espelha padrão MelissaBloqueios/Templates) ─
// No mobile, form (col 1) + variáveis (col 3) viram tabs dentro
// de um drawer único. Só o editor (col 2) fica visível na tela.
const drawerOpen = ref(false);
const drawerTab = ref('form'); // form | vars
const isMobile = ref(false);
let _mqMobile = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false;
}
// Selection salva do editor ativo no momento de abrir o drawer de
// variáveis. Permite inserir na posição original do cursor mesmo
// depois do user navegar pelo drawer/perder foco.
const savedSelection = ref(null);
function openDrawer(tab) {
drawerTab.value = tab || 'form';
// Quando abre "Variáveis", salva selection do editor ativo agora
// (cursor original do usuário) pra restaurar depois da inserção.
if (tab === 'vars') {
const editorMap = {
cabecalho_html: editorCabecalho,
corpo_html: editorCorpo,
rodape_html: editorRodape
};
const editorRef = editorMap[cursorField.value];
savedSelection.value = editorRef?.value?.saveSelection?.() || null;
} else {
savedSelection.value = null;
}
drawerOpen.value = true;
}
function fecharDrawer() {
drawerOpen.value = false;
savedSelection.value = null;
}
onMounted(() => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
isMobile.value = _mqMobile.matches;
try { _mqMobile.addEventListener('change', _onMqMobileChange); }
catch { _mqMobile.addListener(_onMqMobileChange); }
}
});
onBeforeUnmount(() => {
if (_mqMobile) {
try { _mqMobile.removeEventListener('change', _onMqMobileChange); }
catch { _mqMobile.removeListener(_onMqMobileChange); }
}
});
</script>
<style scoped>
/* ═══════ Page chrome (preenche o espaço do container pai) ═══════ */
.dte-page {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
/* padding pra não grudar nas bordas do container pai (mdt-body) */
padding: 12px;
/* fallback pra quando o pai não é flex */
height: 100%;
}
.dte-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 16px;
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 10px;
flex-shrink: 0;
}
.dte-toolbar__title {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.92rem;
font-weight: 600;
color: var(--text-color);
}
.dte-toolbar__title > i {
color: var(--text-color-secondary);
opacity: 0.7;
}
.dte-toolbar__tabs {
display: inline-flex;
align-items: center;
gap: 6px;
}
/* ═══════ 3-col grid (form / editor / variáveis) ═══════ */
.dte-cols {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(240px, 280px) minmax(0, 1fr) minmax(220px, 260px);
gap: 12px;
align-items: stretch;
}
/* COL 1 — Form metadados */
.dte-side {
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 10px;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.dte-side__head {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--surface-border);
font-size: 0.85rem;
font-weight: 600;
color: var(--text-color);
flex-shrink: 0;
}
.dte-side__head > i {
color: var(--text-color-secondary);
opacity: 0.7;
}
.dte-side__body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
}
.dte-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.dte-field label {
font-size: 0.72rem;
font-weight: 600;
color: var(--text-color-secondary);
}
/* COL 2 — Editor com sub-tabs */
.dte-main {
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 10px;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.dte-main__tabs {
display: flex;
align-items: center;
gap: 0;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
flex-shrink: 0;
}
.dte-tab {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 18px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-color-secondary);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: color 140ms ease, border-color 140ms ease, background-color 140ms ease;
font-family: inherit;
}
.dte-tab:hover {
color: var(--text-color);
background: color-mix(in srgb, var(--p-primary-color) 4%, transparent);
}
.dte-tab.is-active {
color: var(--p-primary-color);
border-bottom-color: var(--p-primary-color);
background: color-mix(in srgb, var(--p-primary-color) 6%, transparent);
}
.dte-tab > i { font-size: 0.82rem; }
.dte-main__editor {
flex: 1;
min-height: 0;
overflow: hidden;
padding: 14px;
background: var(--surface-card);
display: flex;
flex-direction: column;
}
.dte-editor-wrap {
flex: 1;
min-height: 450px;
display: flex;
flex-direction: column;
}
.dte-editor-wrap > * {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* Força o Jodit interno a expandir 100% da altura disponível
(substitui o height: minHeight em pixels que o JoditEmailEditor seta) */
.dte-editor-wrap :deep(.jodit-container) {
flex: 1 !important;
height: 100% !important;
min-height: 450px !important;
display: flex !important;
flex-direction: column !important;
}
.dte-editor-wrap :deep(.jodit-workplace) {
flex: 1 !important;
min-height: 0 !important;
}
.dte-editor-wrap :deep(.jodit-wysiwyg) {
min-height: 100% !important;
}
/* COL 3 — Variáveis */
.dte-vars {
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 10px;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.dte-vars__head {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px 8px;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-color);
flex-shrink: 0;
}
.dte-vars__head > i {
color: var(--p-primary-color);
}
.dte-vars__hint {
margin: 0 14px 6px;
font-size: 0.72rem;
color: var(--text-color-secondary);
line-height: 1.4;
flex-shrink: 0;
}
.dte-vars__hint strong {
color: var(--text-color);
}
.dte-vars__list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 4px 10px 12px;
display: flex;
flex-direction: column;
gap: 14px;
scrollbar-width: thin;
}
.dte-vars__group {
display: flex;
flex-direction: column;
gap: 4px;
}
.dte-vars__group-title {
font-size: 0.62rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-color-secondary);
opacity: 0.75;
padding: 0 4px;
}
.dte-vars__group-items {
display: flex;
flex-direction: column;
gap: 2px;
}
.dte-vars__btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-color);
font-size: 0.74rem;
text-align: left;
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease;
font-family: inherit;
}
.dte-vars__btn:hover {
background: color-mix(in srgb, var(--p-primary-color) 10%, transparent);
color: var(--p-primary-color);
}
.dte-vars__btn-brace {
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-size: 0.66rem;
color: var(--p-primary-color);
opacity: 0.6;
}
.dte-vars__btn-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ═══════ Preview ═══════ */
/* Container externo: scroll vertical interno + fundo sutil.
NÃO usa flex (que limitava a altura intrínseca do doc) — usa
block normal com o doc centralizado via margin auto. */
.dte-preview {
flex: 1;
min-height: 0;
overflow-y: auto;
background: var(--surface-ground);
border: 1px solid var(--surface-border);
border-radius: 10px;
padding: 24px;
}
.dte-preview__doc {
background: white;
color: #1a1a1a;
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
width: 100%;
max-width: 794px; /* ≈ A4 a 96dpi */
margin: 0 auto;
padding: 48px 56px;
font-family: 'Segoe UI', Arial, sans-serif;
font-size: 12pt;
line-height: 1.6;
min-height: 500px;
/* Garante que o background-white cresce com o conteúdo
(em vez de ficar travado no min-height quando o doc é grande) */
box-sizing: border-box;
height: auto;
overflow: visible;
}
.dte-preview__header {
text-align: center;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid #ccc;
}
.dte-preview__body {
min-height: 300px;
}
.dte-preview__footer {
margin-top: 32px;
padding-top: 12px;
border-top: 1px solid #ccc;
text-align: center;
font-size: 10pt;
color: #666;
}
/* ═══════ Toolbar mobile actions (botões "Identificação" / "Variáveis") ═══════ */
.dte-toolbar__mobile-actions {
display: none;
align-items: center;
gap: 6px;
}
.dte-mobile-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: 8px;
border: 1px solid var(--surface-border);
background: var(--surface-ground);
color: var(--text-color);
cursor: pointer;
font-size: 0.78rem;
font-weight: 600;
flex-shrink: 0;
font-family: inherit;
transition: background-color 120ms ease;
}
.dte-mobile-btn:hover { background: color-mix(in srgb, var(--p-primary-color) 8%, transparent); }
.dte-mobile-btn > i { color: var(--p-primary-color); font-size: 0.82rem; }
/* ═══════ Mobile drawer (form + variáveis em tabs) ═══════ */
.dte-mobile-drawer {
position: fixed;
top: 0; left: 0;
height: 100vh;
height: 100dvh;
width: min(360px, 92vw);
z-index: 80;
background: var(--surface-card);
border-right: 1px solid var(--surface-border);
transform: translateX(-100%);
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--text-color);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.18);
}
.dte-mobile-drawer.is-open { transform: translateX(0); }
/* Durante a transição de saída, drawer ignora eventos pra não capturar
touch/click "perdidos" e prevenir trava no Jodit. */
.dte-mobile-drawer:not(.is-open) { pointer-events: none; }
.dte-mobile-drawer__tabs {
display: flex;
align-items: stretch;
gap: 0;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
flex-shrink: 0;
}
.dte-drawer-tab {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px 14px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-color-secondary);
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: color 140ms ease, border-color 140ms ease, background-color 140ms ease;
}
.dte-drawer-tab:hover {
color: var(--text-color);
background: color-mix(in srgb, var(--p-primary-color) 4%, transparent);
}
.dte-drawer-tab.is-active {
color: var(--p-primary-color);
border-bottom-color: var(--p-primary-color);
}
.dte-drawer-tab > i { font-size: 0.82rem; }
.dte-drawer-close {
width: 44px;
display: grid;
place-items: center;
background: transparent;
border: none;
color: var(--text-color-secondary);
cursor: pointer;
border-left: 1px solid var(--surface-border);
transition: background-color 120ms ease, color 120ms ease;
}
.dte-drawer-close:hover {
background: var(--surface-ground);
color: var(--text-color);
}
.dte-mobile-drawer__pane {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
scrollbar-width: thin;
}
.dte-mobile-drawer__pane > .dte-side,
.dte-mobile-drawer__pane > .dte-vars {
border: none;
border-radius: 0;
flex: 1;
width: 100%;
}
.dte-mobile-drawer__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 79;
}
.dte-drawer-fade-enter-active,
.dte-drawer-fade-leave-active { transition: opacity 200ms ease; }
.dte-drawer-fade-enter-from,
.dte-drawer-fade-leave-to { opacity: 0; }
/* ═══════ Mobile (<1024px): só o editor visível ═══════ */
@media (max-width: 1023px) {
/* Editor ocupa tela inteira — col 1 e col 3 viram drawer */
.dte-cols {
grid-template-columns: 1fr;
overflow: hidden;
}
.dte-cols > .dte-side,
.dte-cols > .dte-vars { display: none; }
/* Mostra os botões "Identificação" / "Variáveis" no header */
.dte-toolbar__mobile-actions { display: inline-flex; }
/* Esconde o título canônico no mobile (espaço pros botões) */
.dte-toolbar__title > span { display: none; }
.dte-toolbar__title > i { display: none; }
.dte-preview {
padding: 12px;
}
.dte-preview__doc {
padding: 24px 18px;
}
}
</style>
<template>
<div class="flex flex-col gap-3 xl:gap-4">
<!-- Card: Identificação -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center gap-2 px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<i class="pi pi-tag text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-sm">Identificação</span>
<!-- Mobile drawer (form + variáveis em tabs) -->
<Transition name="dte-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="dte-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
>
<div class="dte-mobile-drawer__tabs">
<button
type="button"
class="dte-drawer-tab"
:class="{ 'is-active': drawerTab === 'form' }"
@click="drawerTab = 'form'"
>
<i class="pi pi-tag" />
<span>Identificação</span>
</button>
<button
type="button"
class="dte-drawer-tab"
:class="{ 'is-active': drawerTab === 'vars' }"
@click="drawerTab = 'vars'"
>
<i class="pi pi-code" />
<span>Variáveis</span>
</button>
<button
type="button"
class="dte-drawer-close"
v-tooltip.bottom="'Fechar'"
@click="fecharDrawer"
>
<i class="pi pi-times" />
</button>
</div>
<div class="p-4 flex flex-col gap-3">
<div class="grid grid-cols-1 sm:grid-cols-[1fr_220px] gap-3">
<div class="flex flex-col gap-1">
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Nome do template</label>
<div id="dte-mobile-drawer-form" v-show="drawerTab === 'form'" class="dte-mobile-drawer__pane" />
<div id="dte-mobile-drawer-vars" v-show="drawerTab === 'vars'" class="dte-mobile-drawer__pane" />
</div>
</Transition>
<Transition name="dte-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="dte-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<div class="dte-page">
<!-- Toggle Editor / Preview no topo -->
<div class="dte-toolbar">
<!-- Botões "Identificação" e "Variáveis" mobile-only -->
<div class="dte-toolbar__mobile-actions">
<button
type="button"
class="dte-mobile-btn"
v-tooltip.bottom="'Identificação do template'"
@click="openDrawer('form')"
>
<i class="pi pi-tag" />
<span>Identificação</span>
</button>
<button
type="button"
class="dte-mobile-btn"
v-tooltip.bottom="'Inserir variáveis'"
@click="openDrawer('vars')"
>
<i class="pi pi-code" />
<span>Variáveis</span>
</button>
</div>
<div class="dte-toolbar__title">
<i class="pi pi-file-edit" />
<span>Conteúdo do documento</span>
</div>
<div class="dte-toolbar__tabs">
<Button
label="Editor"
icon="pi pi-pencil"
:severity="activeTab === 'editor' ? undefined : 'secondary'"
:outlined="activeTab !== 'editor'"
size="small"
class="rounded-full"
@click="activeTab = 'editor'"
/>
<Button
label="Preview"
icon="pi pi-eye"
:severity="activeTab === 'preview' ? undefined : 'secondary'"
:outlined="activeTab !== 'preview'"
size="small"
class="rounded-full"
@click="activeTab = 'preview'"
/>
</div>
</div>
<!-- EDITOR 3 colunas (form / editor / variáveis) -->
<div v-show="activeTab === 'editor'" class="dte-cols">
<!-- COL 1 (esquerda): Form de metadados teleporta pro drawer no mobile -->
<Teleport to="#dte-mobile-drawer-form" :disabled="!isMobile">
<aside class="dte-side">
<div class="dte-side__head">
<i class="pi pi-tag" />
<span>Identificação</span>
</div>
<div class="dte-side__body">
<div class="dte-field">
<label>Nome do template</label>
<InputText v-model="form.nome_template" placeholder="Ex: Declaração de comparecimento" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Tipo</label>
<div class="dte-field">
<label>Tipo</label>
<Select v-model="form.tipo" :options="TIPOS_TEMPLATE" optionLabel="label" optionValue="value" class="w-full" />
</div>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Descrição</label>
<InputText v-model="form.descricao" placeholder="Breve descrição do template" class="w-full" />
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">URL do logo (opcional)</label>
<InputText v-model="form.logo_url" placeholder="https://..." class="w-full" />
</div>
</div>
</div>
<!-- Card: Conteúdo -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center justify-between gap-2 px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2 min-w-0">
<i class="pi pi-file-edit text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-sm">Conteúdo do documento</span>
</div>
<!-- Tabs: Editor / Preview -->
<div class="flex items-center gap-1">
<Button
:label="'Editor'"
icon="pi pi-pencil"
:severity="activeTab === 'editor' ? undefined : 'secondary'"
:outlined="activeTab !== 'editor'"
size="small"
class="rounded-full"
@click="activeTab = 'editor'"
/>
<Button
:label="'Preview'"
icon="pi pi-eye"
:severity="activeTab === 'preview' ? undefined : 'secondary'"
:outlined="activeTab !== 'preview'"
size="small"
class="rounded-full"
@click="activeTab = 'preview'"
/>
</div>
</div>
<!-- Editor -->
<div v-show="activeTab === 'editor'" class="p-4 flex flex-col lg:flex-row gap-4">
<!-- Campos HTML -->
<div class="flex-1 min-w-0 flex flex-col gap-3">
<div class="flex flex-col gap-1" @focusin="cursorField = 'cabecalho_html'">
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Cabeçalho</label>
<JoditEmailEditor ref="editorCabecalho" v-model="form.cabecalho_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
<div class="dte-field">
<label>Descrição</label>
<Textarea v-model="form.descricao" placeholder="Breve descrição do template" class="w-full" rows="3" autoResize />
</div>
<div class="flex flex-col gap-1" @focusin="cursorField = 'corpo_html'">
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Corpo do documento</label>
<JoditEmailEditor ref="editorCorpo" v-model="form.corpo_html" :minHeight="350" />
</div>
<div class="flex flex-col gap-1" @focusin="cursorField = 'rodape_html'">
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Rodapé</label>
<JoditEmailEditor ref="editorRodape" v-model="form.rodape_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
<div class="dte-field">
<label>URL do logo (opcional)</label>
<InputText v-model="form.logo_url" placeholder="https://..." class="w-full" />
</div>
</div>
</aside>
</Teleport>
<!-- Painel de variáveis -->
<div class="w-full lg:w-[240px] shrink-0">
<div class="lg:sticky lg:top-3 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)]/50 overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 border-b border-[var(--surface-border,#e2e8f0)]">
<i class="pi pi-code text-[var(--text-color-secondary)] opacity-60 text-xs" />
<span class="font-semibold text-xs">Variáveis</span>
</div>
<div class="px-3 pt-2 text-[0.68rem] text-[var(--text-color-secondary)] opacity-75 italic">Clique para inserir no campo ativo</div>
<div class="flex flex-col gap-3 p-3 max-h-[500px] overflow-y-auto">
<div v-for="(vars, grupo) in variablesGrouped" :key="grupo">
<div class="text-[0.62rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-1">{{ grupo }}</div>
<div class="flex flex-col gap-0.5">
<button
v-for="v in vars"
:key="v.key"
class="text-left text-xs px-2 py-1 rounded-md bg-transparent border-none hover:bg-[var(--primary-color,#6366f1)]/10 hover:text-[var(--primary-color,#6366f1)] transition-colors truncate cursor-pointer"
:title="v.key"
@click="insertVariable(v.key)"
>
<span class="font-mono text-[0.62rem] opacity-60">&lbrace;&lbrace;</span>
{{ v.label }}
<span class="font-mono text-[0.62rem] opacity-60">&rbrace;&rbrace;</span>
</button>
</div>
</div>
<!-- COL 2 (centro): Tabs Cabeçalho/Corpo/Rodapé + editor -->
<main class="dte-main">
<div class="dte-main__tabs">
<button
type="button"
class="dte-tab"
:class="{ 'is-active': editorTab === 'cabecalho' }"
@click="editorTab = 'cabecalho'"
>
<i class="pi pi-align-left" />
<span>Cabeçalho</span>
</button>
<button
type="button"
class="dte-tab"
:class="{ 'is-active': editorTab === 'corpo' }"
@click="editorTab = 'corpo'"
>
<i class="pi pi-align-justify" />
<span>Corpo</span>
</button>
<button
type="button"
class="dte-tab"
:class="{ 'is-active': editorTab === 'rodape' }"
@click="editorTab = 'rodape'"
>
<i class="pi pi-align-center" />
<span>Rodapé</span>
</button>
</div>
<div class="dte-main__editor">
<div v-show="editorTab === 'cabecalho'" class="dte-editor-wrap" @focusin="cursorField = 'cabecalho_html'">
<JoditEmailEditor ref="editorCabecalho" v-model="form.cabecalho_html" :minHeight="450" layoutButtons :logoUrl="form.logo_url" />
</div>
<div v-show="editorTab === 'corpo'" class="dte-editor-wrap" @focusin="cursorField = 'corpo_html'">
<JoditEmailEditor ref="editorCorpo" v-model="form.corpo_html" :minHeight="450" />
</div>
<div v-show="editorTab === 'rodape'" class="dte-editor-wrap" @focusin="cursorField = 'rodape_html'">
<JoditEmailEditor ref="editorRodape" v-model="form.rodape_html" :minHeight="450" layoutButtons :logoUrl="form.logo_url" />
</div>
</div>
</main>
<!-- COL 3 (direita): Variáveis disponíveis teleporta pro drawer no mobile -->
<Teleport to="#dte-mobile-drawer-vars" :disabled="!isMobile">
<aside class="dte-vars">
<div class="dte-vars__head">
<i class="pi pi-code" />
<span>Variáveis</span>
</div>
<p class="dte-vars__hint">
Clique para inserir no
<strong>{{ editorTab === 'cabecalho' ? 'Cabeçalho' : editorTab === 'rodape' ? 'Rodapé' : 'Corpo' }}</strong>.
</p>
<div class="dte-vars__list">
<div v-for="(vars, grupo) in variablesGrouped" :key="grupo" class="dte-vars__group">
<div class="dte-vars__group-title">{{ grupo }}</div>
<div class="dte-vars__group-items">
<button
v-for="v in vars"
:key="v.key"
class="dte-vars__btn"
:title="v.key"
@click="insertVariable(v.key)"
>
<span class="dte-vars__btn-brace">&lbrace;&lbrace;</span>
<span class="dte-vars__btn-label">{{ v.label }}</span>
<span class="dte-vars__btn-brace">&rbrace;&rbrace;</span>
</button>
</div>
</div>
</div>
</div>
<!-- Preview -->
<div v-show="activeTab === 'preview'" class="p-4">
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-white overflow-hidden">
<div class="p-6 text-black" style="font-family: 'Segoe UI', Arial, sans-serif; font-size: 12pt; line-height: 1.6;">
<div v-if="form.cabecalho_html" class="text-center mb-4 pb-3 border-b border-gray-300" v-html="renderedCabecalho" />
<div class="min-h-[300px]" v-html="renderedPreview" />
<div v-if="form.rodape_html" class="mt-8 pt-3 border-t border-gray-300 text-center text-[10pt] text-gray-500" v-html="renderedRodape" />
</div>
</div>
</div>
</aside>
</Teleport>
</div>
<!-- PREVIEW full width -->
<div v-show="activeTab === 'preview'" class="dte-preview">
<div class="dte-preview__doc">
<div v-if="form.cabecalho_html" class="dte-preview__header" v-html="renderedCabecalho" />
<div class="dte-preview__body" v-html="renderedPreview" />
<div v-if="form.rodape_html" class="dte-preview__footer" v-html="renderedRodape" />
</div>
</div>
</div>
</template>
@@ -47,8 +47,15 @@ export function useDocumentGenerate() {
error.value = null;
try {
variables.value = await loadAllVariables(patientId, agendaEventoId);
// Hint útil pra diagnostico: se vier objeto mas todos campos vazios,
// sinaliza que perfil/clínica/paciente provavelmente nao tem dados.
const filled = Object.values(variables.value).filter(v => String(v ?? '').trim() !== '').length;
if (filled === 0) {
error.value = 'Nenhum dado foi encontrado pra auto-preencher. Verifique o cadastro do paciente, perfil e clínica.';
}
} catch (e) {
error.value = e?.message || 'Erro ao carregar dados do paciente.';
console.error('[useDocumentGenerate.loadVariables] falha:', e);
error.value = e?.message || 'Erro ao carregar dados pra preenchimento.';
variables.value = {};
} finally {
loading.value = false;
@@ -92,9 +99,12 @@ export function useDocumentGenerate() {
// ── Gerar PDF (client-side) ────────────────────────────
/**
* Gera PDF blob, faz download, salva no Storage + banco.
* Gera PDF blob, salva no Storage + banco.
* @param {string} patientId
* @param {string|null} editingDocId - se setado, UPDATE no doc existente
* (in-place replace de PDF + metadados, preserva documents.id e audit).
*/
async function generateAndSave(patientId) {
async function generateAndSave(patientId, editingDocId = null) {
if (!selectedTemplate.value) throw new Error('Nenhum template selecionado.');
loading.value = true;
@@ -112,7 +122,8 @@ export function useDocumentGenerate() {
dadosPreenchidos: { ...variables.value },
pdfBlob: blob,
templateNome,
templateTipo: selectedTemplate.value.tipo
templateTipo: selectedTemplate.value.tipo,
editingDocId
});
generatedDocs.value.unshift(result);
return result;
+3 -2
View File
@@ -620,8 +620,9 @@ onMounted(async () => {
<NotificationDrawer />
</div>
<!-- Ajuda -->
<button type="button" class="rail-topbar__btn" :class="{ 'rail-topbar__btn--active': ajudaDrawerOpen }" :title="ajudaDrawerOpen ? 'Fechar ajuda' : 'Ajuda'" @click="toggleAjuda">
<!-- Ajuda data-ajuda-toggle ignora este botao no
click-outside do AjudaDrawer (senao fecha + reabre). -->
<button type="button" class="rail-topbar__btn" :class="{ 'rail-topbar__btn--active': ajudaDrawerOpen }" :title="ajudaDrawerOpen ? 'Fechar ajuda' : 'Ajuda'" data-ajuda-toggle @click="toggleAjuda">
<i class="pi pi-question-circle" />
</button>
+15
View File
@@ -210,6 +210,21 @@ const grupos = [
}
]
},
{
key: 'documentos',
label: 'Documentos',
desc: 'Modelos e geração de recibos, atestados, laudos, TCLE e LGPD.',
icon: 'pi pi-file',
items: [
{
key: 'documentos-templates',
label: 'Modelos de documentos',
desc: 'Cadastre e edite templates de recibos, atestados, laudos, TCLE, LGPD e mais.',
icon: 'pi pi-file-edit',
to: '/configuracoes/documentos/templates'
}
]
},
{
key: 'plataforma',
label: 'Empresa & Plataforma',
+22 -50
View File
@@ -31,7 +31,6 @@ import ProximosFeriadosCard from '@/features/agenda/components/ProximosFeriadosC
import MelissaAgendaHistoricoCard from './MelissaAgendaHistoricoCard.vue';
import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue';
import Popover from 'primevue/popover';
import MelissaAgendaSearchPopover from './MelissaAgendaSearchPopover.vue';
import MelissaAgendaActionsPopover from './MelissaAgendaActionsPopover.vue';
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
@@ -691,29 +690,15 @@ const fcOptions = computed(() => ({
}));
// ── Busca da toolbar (datas + paciente/título) ────────────────
// Componente MelissaAgendaSearchPopover (autocontido) gerencia o Cmd+K
// search inteiro — input, debounce, parsing de datas, resultados.
// Aqui só ficam o ref pro toggle (botão da toolbar + hotkey global) e
// os 2 handlers que decidem o que fazer com a escolha (gotoDate +
// auto-select de paciente quando há patient_id no evento).
const searchPopover = ref(null);
// Busca agora vive na MelissaBusca (global, Ctrl+K em qualquer tela ou
// botao na .melissa-tray). Aqui fica so a fn gotoDate exposta pro
// MelissaLayout chamar quando o usuario escolhe "Ir para [data]" na
// busca global — vide defineExpose mais abaixo.
function onBuscaGotoDate(date) {
fcApi()?.gotoDate(date);
refDate.value = new Date(date);
}
function onBuscaSelectEvento(ev) {
if (!ev?.inicio_em) return;
fcApi()?.gotoDate(ev.inicio_em);
refDate.value = new Date(ev.inicio_em);
// Auto-seleciona o paciente se o evento tiver um — assim a agenda já
// fica filtrada por ele e o dock contextual aparece.
if (ev.patient_id) {
pacienteSelecionadoId.value = ev.patient_id;
}
}
// Card de histórico (audit_logs) — ref pra disparar refetch após
// mutações; handler que abre o evento clicado pelo id.
const historicoCardRef = ref(null);
@@ -760,18 +745,8 @@ async function onHistoricoOpen({ id }) {
}
}
// Atalho global Ctrl/Cmd+K abre a busca via ref do popover.
function _onSearchHotkey(e) {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
// Anchor virtual no botão da toolbar — necessário pra Popover do
// PrimeVue posicionar corretamente.
const btn = document.querySelector('.ma-cal__search-btn');
if (btn) searchPopover.value?.toggle({ currentTarget: btn, target: btn });
}
}
onMounted(() => { window.addEventListener('keydown', _onSearchHotkey); });
onBeforeUnmount(() => { window.removeEventListener('keydown', _onSearchHotkey); });
// Ctrl+K e' tratado pela propria MelissaBusca (listener global no
// window) — removido o handler local pra nao disparar 2 vezes.
// Toolbar — atalhos pra FC API
function fcApi() {
@@ -1321,12 +1296,20 @@ function openProntuario(patient) {
if (!patient?.id) return;
abrirProntuarioPorId(patient.id);
}
// gotoDate exposto pro MelissaLayout chamar quando o usuario escolhe
// "Ir para [data]" na MelissaBusca (busca global). Reusa onBuscaGotoDate
// que ja atualiza fcApi + refDate.
function gotoDateExternal(date) {
onBuscaGotoDate(date);
}
defineExpose({
refetch: refetchEventosFc,
openProntuario,
setView,
openSessoesPaciente,
openEditPatient
openEditPatient,
gotoDate: gotoDateExternal
});
</script>
@@ -1629,21 +1612,10 @@ defineExpose({
/>
</div>
<!-- Busca sempre visível. Abre popover com input + lista de
resultados. Suporta data (20/04, hoje) e texto (paciente/
título). Ctrl/Cmd+K abre via hotkey global. -->
<button
class="ma-cal__icon ma-cal__search-btn w-7 h-7 grid place-items-center bg-transparent border-0 text-[var(--m-text)] rounded-md cursor-pointer text-[0.78rem] transition-colors duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] focus-visible:outline-2 focus-visible:outline-[var(--m-accent)] focus-visible:outline-offset-2"
v-tooltip.top="'Buscar (Ctrl+K)'"
@click="searchPopover?.toggle($event)"
>
<i class="pi pi-search" />
</button>
<MelissaAgendaSearchPopover
ref="searchPopover"
@goto-date="onBuscaGotoDate"
@select-evento="onBuscaSelectEvento"
/>
<!-- Busca migrou pra .melissa-tray (sempre visivel).
Ctrl+K em qualquer tela abre o mesmo spotlight,
que ja entende data (20/04, hoje, amanha) e
paciente/sessao via RPC search_global. -->
<!-- Bloquear: ícone-only com Menu popup. Visível só
em ≥xl. Em <xl vai pra dentro de "Ações". -->
@@ -2051,9 +2023,9 @@ defineExpose({
to { opacity: 1; transform: scale(1); }
}
/* .ma-tsearch* migrou inteiro pra MelissaAgendaSearchPopover.vue (componente
autocontido). Cmd+K hotkey global continua aqui no parent — chama
`searchPopover.value?.toggle(...)` apontando pro botao .ma-cal__search-btn. */
/* Busca da agenda migrou inteira pra MelissaBusca (componente global,
no MelissaLayout). Acesso por Ctrl+K em qualquer tela ou pela lupa na
.melissa-tray (canto inferior direito). Toolbar nao tem mais botao. */
/* .ma-actions* migrou inteiro pra MelissaAgendaActionsPopover.vue
(componente autocontido, com utilities Tailwind no template). */
@@ -1,327 +0,0 @@
<script setup>
/*
* MelissaAgendaSearchPopover busca da toolbar da Agenda Melissa
* --------------------------------------------------------------
* Pattern Cmd+K: input + lista de resultados num Popover. Suporta:
* - Datas: "20/04", "20/04/2026", "hoje", "amanhã", "ontem"
* emit('goto-date', date) pai navega o FullCalendar
* - Texto livre: pesquisa server-side em patients.nome_completo + titulo
* (via searchEventosByText) com debounce de 300ms. Limite 20 resultados.
* emit('select-evento', ev) pai aciona gotoDate + auto-select patient
*
* Componente autocontido owns todo o state, debounce, parsing.
* Pai expõe um botão âncora (`.ma-cal__search-btn`) e chama
* `popoverRef.value.toggle($event)` no click. Hotkey Cmd+K também
* vive no pai (acha o botão via querySelector e chama toggle).
*
* Emit:
* - goto-date(date: Date) escolheu uma data do parser
* - select-evento(ev) escolheu um evento da lista de busca
*
* Exposto via defineExpose:
* - toggle(event) abre/fecha o popover, foca input quando abre
*/
import { ref, onBeforeUnmount } from 'vue';
import Popover from 'primevue/popover';
import { searchEventosByText } from './composables/useMelissaEventos';
const emit = defineEmits(['goto-date', 'select-evento']);
const popRef = ref(null);
const inputRef = ref(null);
const searchQuery = ref('');
const searchResults = ref([]);
const searchLoading = ref(false);
const searchDateMatch = ref(null); // Date | null preenchido se query parsear como data
let _debounceTimer = null;
// Token monotonico protege contra race condition: se o user digita "ab"
// e depois "abc", o request "ab" pode resolver DEPOIS do "abc" em conexao
// lenta. Cada request guarda seu token; ao voltar, so aplica se ainda eh
// o mais recente. Cancelamento via AbortController seria ideal mas exigiria
// searchEventosByText aceitar signal por ora token resolve sem mexer no API.
let _searchToken = 0;
function parseSearchAsDate(str) {
const t = String(str || '').trim().toLowerCase();
if (!t) return null;
if (t === 'hoje') { const d = new Date(); d.setHours(0,0,0,0); return d; }
if (t === 'amanha' || t === 'amanhã') {
const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() + 1); return d;
}
if (t === 'ontem') {
const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() - 1); return d;
}
// DD/MM ou DD/MM/YYYY (também aceita - e .)
const m = t.match(/^(\d{1,2})[/\-.](\d{1,2})(?:[/\-.](\d{2,4}))?$/);
if (m) {
const day = parseInt(m[1], 10);
const month = parseInt(m[2], 10);
let year = parseInt(m[3] || '', 10);
if (!year || Number.isNaN(year)) year = new Date().getFullYear();
if (year < 100) year += 2000;
if (day < 1 || day > 31 || month < 1 || month > 12) return null;
const d = new Date(year, month - 1, day);
if (Number.isNaN(d.getTime())) return null;
return d;
}
return null;
}
function toggle(event) {
popRef.value?.toggle(event);
// Foco no input quando abrir (Popover anima ~150ms). PrimeVue InputText
// renderiza `<input>` direto, então `$el` já é o elemento focável.
// Tentamos algumas vezes pra cobrir mount async + transição do Popover.
let tries = 0;
const tick = () => {
const el = inputRef.value?.$el;
if (el && typeof el.focus === 'function') { el.focus(); el.select?.(); return; }
if (tries++ < 8) setTimeout(tick, 30);
};
setTimeout(tick, 80);
}
function fechar() {
try { popRef.value?.hide(); } catch {}
}
// Toda vez que o popover fecha (via ESC, click-fora, ou submit/select),
// reseta o state pra proxima abertura comecar limpa. Antes, ESC mantinha
// query+resultados "fantasmas" UX confusa: reabrir mostrava busca
// antiga que talvez nao fizesse mais sentido.
function onPopoverHide() {
searchQuery.value = '';
searchResults.value = [];
searchDateMatch.value = null;
searchLoading.value = false;
++_searchToken; // invalida requests em flight
}
function onSearchInput() {
if (_debounceTimer) clearTimeout(_debounceTimer);
const q = searchQuery.value;
searchDateMatch.value = parseSearchAsDate(q);
// Se digitou data, mostra resultado imediato (sem hit no DB)
if (searchDateMatch.value) {
// Invalida tokens em flight pra eles nao voltarem e sobrescreverem [].
++_searchToken;
searchResults.value = [];
searchLoading.value = false;
return;
}
if (String(q || '').trim().length < 2) {
++_searchToken;
searchResults.value = [];
searchLoading.value = false;
return;
}
searchLoading.value = true;
_debounceTimer = setTimeout(async () => {
const myToken = ++_searchToken;
try {
const results = await searchEventosByText(q);
// Race guard: se outro request foi disparado depois deste,
// descarta este (out-of-order resolution).
if (myToken !== _searchToken) return;
searchResults.value = results;
} finally {
// So zera loading se este eh o ultimo request senao deixa o
// proximo controlar o estado (evita flicker de loading=false
// entre requests sequenciais rapidos).
if (myToken === _searchToken) searchLoading.value = false;
}
}, 300);
}
function onSearchSubmit() {
// Enter: prioriza data, senão pega o primeiro resultado
if (searchDateMatch.value) {
irParaData(searchDateMatch.value);
return;
}
if (searchResults.value.length > 0) {
selecionarResultado(searchResults.value[0]);
}
}
function irParaData(date) {
emit('goto-date', date);
fechar(); // @hide handler limpa state
}
function selecionarResultado(ev) {
if (!ev?.inicio_em) return;
emit('select-evento', ev);
fechar(); // @hide handler limpa state
}
function fmtDataResultado(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', weekday: 'short' });
}
function fmtHoraResultado(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
onBeforeUnmount(() => {
if (_debounceTimer) clearTimeout(_debounceTimer);
});
defineExpose({ toggle, close: fechar });
</script>
<template>
<Popover ref="popRef" class="ma-tsearch-pop" @hide="onPopoverHide">
<div class="ma-tsearch flex flex-col w-[min(440px,calc(100vw-32px))] max-h-[500px] overflow-hidden">
<div class="ma-tsearch__field relative flex items-center gap-2 px-2.5 py-1.5 mx-2 mt-2 mb-1.5 bg-[var(--m-bg-soft)] border border-[var(--m-border)] rounded-[10px] flex-shrink-0 transition-[border-color,background-color] duration-[140ms] focus-within:border-[var(--p-primary-color)] focus-within:bg-[var(--m-bg-soft-hover)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--p-primary-color)_12%,transparent)]">
<i class="pi pi-search ma-tsearch__field-icon text-[var(--m-text-muted)] text-[0.85rem] flex-shrink-0" />
<InputText
ref="inputRef"
v-model="searchQuery"
placeholder="Data (20/04) ou nome do paciente…"
class="ma-tsearch__input"
@input="onSearchInput"
@keydown.enter="onSearchSubmit"
@keydown.esc="fechar"
/>
<button
v-if="searchQuery"
class="ma-tsearch__clear w-[22px] h-[22px] grid place-items-center border-0 bg-[var(--m-bg-medium)] text-[var(--m-text-muted)] rounded-full cursor-pointer flex-shrink-0 transition-colors duration-[140ms] hover:bg-[var(--m-border-strong)] hover:text-[var(--m-text)]"
v-tooltip.top="'Limpar'"
@click="searchQuery = ''; onSearchInput()"
>
<i class="pi pi-times text-xs" />
</button>
</div>
<!-- Resultado data (sem hit no DB) -->
<button
v-if="searchDateMatch"
class="ma-tsearch__result ma-tsearch__result--date w-auto self-stretch flex items-center gap-2.5 px-2.5 py-2 mx-2 mb-1.5 rounded-lg cursor-pointer text-left [font-family:inherit] transition-colors duration-[140ms]"
@click="irParaData(searchDateMatch)"
>
<span class="ma-tsearch__result-icon w-8 h-8 grid place-items-center rounded-lg flex-shrink-0 text-[0.78rem]"><i class="pi pi-calendar" /></span>
<span class="ma-tsearch__result-main flex-1 min-w-0 flex flex-col gap-0.5">
<span class="ma-tsearch__result-title text-[0.85rem] font-medium whitespace-nowrap overflow-hidden text-ellipsis capitalize">Ir para {{ searchDateMatch.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', weekday: 'long' }) }}</span>
<span class="ma-tsearch__result-sub text-[0.72rem] text-[var(--m-text-muted)] whitespace-nowrap overflow-hidden text-ellipsis">Pular para essa data no calendário</span>
</span>
<i class="pi pi-arrow-right text-xs opacity-50" />
</button>
<!-- Loading -->
<div v-else-if="searchLoading" class="ma-tsearch__loading flex items-center gap-2 px-3.5 py-[18px] text-[var(--m-text-muted)] text-[0.85rem]">
<i class="pi pi-spin pi-spinner" /> <span>Buscando</span>
</div>
<!-- Resultados (eventos) -->
<div v-else-if="searchResults.length > 0" class="ma-tsearch__results flex-1 min-h-0 overflow-y-auto p-1">
<button
v-for="ev in searchResults"
:key="ev.id"
class="ma-tsearch__result w-full flex items-center gap-2.5 px-2.5 py-2 border-0 bg-transparent text-[var(--m-text)] rounded-lg cursor-pointer text-left [font-family:inherit] transition-colors duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] focus-visible:bg-[var(--m-bg-soft-hover)] focus-visible:outline-none"
@click="selecionarResultado(ev)"
>
<span class="ma-tsearch__result-icon w-8 h-8 grid place-items-center rounded-lg text-white flex-shrink-0 text-[0.78rem]" :style="{ background: ev.color }">
<i :class="ev.tipo === 'sessao' ? 'pi pi-user' : 'pi pi-calendar'" />
</span>
<span class="ma-tsearch__result-main flex-1 min-w-0 flex flex-col gap-0.5">
<span class="ma-tsearch__result-title text-[0.85rem] font-medium text-[var(--m-text)] whitespace-nowrap overflow-hidden text-ellipsis">{{ ev.label }}</span>
<span class="ma-tsearch__result-sub text-[0.72rem] text-[var(--m-text-muted)] whitespace-nowrap overflow-hidden text-ellipsis">
{{ fmtDataResultado(ev.inicio_em) }} · {{ fmtHoraResultado(ev.inicio_em) }}
<span v-if="ev.modalidade"> · {{ ev.modalidade }}</span>
</span>
</span>
</button>
</div>
<!-- Vazio ( buscou mas nada encontrou) -->
<div v-else-if="String(searchQuery || '').trim().length >= 2" class="ma-tsearch__empty flex flex-col items-center gap-2 px-4 py-[26px] mx-2 mb-2.5 mt-1 text-[var(--m-text-muted)] text-[0.82rem] border-[1.5px] border-dashed border-[color-mix(in_srgb,var(--p-primary-color)_22%,var(--m-border))] rounded-[14px] bg-[color-mix(in_srgb,var(--m-bg-soft)_50%,transparent)] text-center [&>i]:text-[2.2rem] [&>i]:opacity-70 [&>i]:text-[var(--p-primary-color)] [&>i]:mb-0.5">
<i class="pi pi-search-minus" />
<span class="ma-tsearch__empty-title text-[0.88rem] font-semibold text-[var(--m-text)]">Busca não encontrada</span>
<span class="ma-tsearch__empty-sub text-[0.78rem] text-[var(--m-text-muted)] leading-[1.35] [&_strong]:text-[var(--m-text)] [&_strong]:font-semibold">
Nada para "<strong>{{ searchQuery }}</strong>"
</span>
</div>
<!-- Hint inicial Message PrimeVue (auto-resolve via PrimeVueResolver) -->
<Message
v-else
severity="info"
:closable="false"
icon="pi pi-info-circle"
class="ma-tsearch__hint mx-2 mb-2.5"
>
Digite uma data (<strong>20/04</strong>, <strong>hoje</strong>, <strong>amanhã</strong>) ou
o nome do paciente (<strong>André</strong>).
</Message>
</div>
</Popover>
</template>
<style scoped>
/* Estilos que NAO migram pra utilities Tailwind:
- .ma-tsearch__input.p-inputtext: override do PrimeVue InputText pra ele
viver dentro do "field box" (zera bordas, bg, shadow padrao). Selector
composto com classe externa do PrimeVue.
- .ma-tsearch__hint :deep(.p-message-text): override de typography dentro
do componente filho Message (isolado por scope).
- .ma-tsearch__result--date: a versao "Ir para data" tem cores azul fixas
(rgb diretas, sem var()) com !important pra blindar contra hover do
base .ma-tsearch__result. State modifier mais limpo em CSS. */
.ma-tsearch__input.p-inputtext {
flex: 1;
border: none;
background: transparent;
outline: none;
box-shadow: none;
padding: 8px 0;
font-size: 0.9rem;
color: var(--m-text);
min-width: 0;
}
.ma-tsearch__input.p-inputtext:enabled:focus,
.ma-tsearch__input.p-inputtext:enabled:hover {
box-shadow: none;
border-color: transparent;
background: transparent;
}
.ma-tsearch__input.p-inputtext::placeholder { color: var(--m-text-muted); }
.ma-tsearch__hint :deep(.p-message-text) {
font-size: 0.82rem;
line-height: 1.4;
}
.ma-tsearch__hint strong { font-weight: 600; }
/* "Ir para data" destaque azul claro independente da primary do tenant.
Cores diretas (sem color-mix com bg-soft) pra garantir contraste em
dark mode onde --m-bg-soft tem alpha 50% e diluiria o tint demais.
!important pra blindar contra hover do .ma-tsearch__result base. */
.ma-tsearch__result--date {
background: rgba(59, 130, 246, 0.16) !important;
border: 1.5px solid rgba(59, 130, 246, 0.55) !important;
}
.ma-tsearch__result--date:hover,
.ma-tsearch__result--date:focus-visible {
background: rgba(59, 130, 246, 0.26) !important;
border-color: rgba(59, 130, 246, 0.75) !important;
}
.ma-tsearch__result--date .ma-tsearch__result-icon {
background: #3b82f6 !important;
color: white !important;
}
.ma-tsearch__result--date .ma-tsearch__result-title {
color: #2563eb !important; /* blue-600 — boa leitura em ambos os modos */
}
/* Dark mode — clareia o título pra contrastar com o fundo escuro */
html.app-dark .ma-tsearch__result--date .ma-tsearch__result-title {
color: #93c5fd !important; /* blue-300 */
}
html.app-dark .ma-tsearch__result--date .ma-tsearch__result-sub {
color: rgba(147, 197, 253, 0.7) !important;
}
</style>
+325 -113
View File
@@ -14,7 +14,7 @@
* Quando promover pra produção: trocar a busca por chamada à RPC
* `search_global` + manter a mesma estrutura de panel/items.
*/
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useRecentPatients } from '@/composables/useRecentPatients';
@@ -33,7 +33,7 @@ const props = defineProps({
}
});
const emit = defineEmits(['acao', 'paciente', 'evento', 'documento', 'intake']);
const emit = defineEmits(['acao', 'paciente', 'evento', 'documento', 'intake', 'goto-date']);
const rootEl = ref(null);
const inputEl = ref(null);
@@ -62,12 +62,59 @@ function normalize(s) {
.trim();
}
// Parser de data portado de MelissaAgendaSearchPopover.
// Aceita: "hoje", "amanha"/"amanhã", "ontem", "DD/MM", "DD/MM/YYYY"
// (separadores /, - ou .). Retorna Date|null. Acao "Ir para esta data"
// so se torna visivel quando ha match (vide dateMatch computed).
function parseSearchAsDate(str) {
const t = String(str || '').trim().toLowerCase();
if (!t) return null;
if (t === 'hoje') { const d = new Date(); d.setHours(0, 0, 0, 0); return d; }
if (t === 'amanha' || t === 'amanhã') {
const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + 1); return d;
}
if (t === 'ontem') {
const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - 1); return d;
}
const m = t.match(/^(\d{1,2})[/\-.](\d{1,2})(?:[/\-.](\d{2,4}))?$/);
if (m) {
const day = parseInt(m[1], 10);
const month = parseInt(m[2], 10);
let year = parseInt(m[3] || '', 10);
if (!year || Number.isNaN(year)) year = new Date().getFullYear();
if (year < 100) year += 2000;
if (day < 1 || day > 31 || month < 1 || month > 12) return null;
const d = new Date(year, month - 1, day);
if (Number.isNaN(d.getTime())) return null;
return d;
}
return null;
}
function fmtHora(h) {
const horas = Math.floor(h);
const mins = Math.round((h - horas) * 60);
return `${String(horas).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
// Match de data se a query parseia como data, a primeira "linha" do
// painel vira um card destacado "Ir para [data]" (igual ao popover da
// agenda). Click/Enter dispara emit('goto-date', date) e o MelissaLayout
// abre a agenda + navega o calendario.
const dateMatch = computed(() => parseSearchAsDate(query.value));
function fmtDataLonga(d) {
if (!(d instanceof Date) || Number.isNaN(d.getTime())) return '';
// "Sábado, 20/06/2026" primeira letra maiuscula no weekday
const s = d.toLocaleDateString('pt-BR', {
weekday: 'long',
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
return s.charAt(0).toUpperCase() + s.slice(1);
}
const filteredAtalhos = computed(() => {
const q = normalize(query.value);
if (!q) return props.atalhos.slice(0, 4); // 4 defaults quando vazio
@@ -79,6 +126,9 @@ const filteredAtalhos = computed(() => {
// Pacientes combina RPC (autoritativo, todos os pacientes) com props (preview de hoje).
// RPC tem prioridade; props complementa quando RPC ainda não trouxe nada.
//
// Shape do RPC search_global (patients): { id, label, sublabel, avatar_url, deeplink, score }
// label = nome_completo; sublabel = email_principal ou telefone.
const filteredPacientes = computed(() => {
const q = normalize(query.value);
if (q.length < 2) return [];
@@ -87,15 +137,16 @@ const filteredPacientes = computed(() => {
if (rpc.length) {
return rpc.slice(0, 5).map(p => ({
id: p.id,
nome: p.nome_completo || p.nome_social || p.nome || '(sem nome)',
email: p.email,
telefone: p.telefone
nome: p.label || '(sem nome)',
sub: p.sublabel || '',
avatar_url: p.avatar_url || null
}));
}
// Fallback client-side
// Fallback client-side (props.pacientes vem do MelissaLayout shape diferente)
return props.pacientes
.filter((p) => normalize(p.nome).includes(q))
.slice(0, 5);
.slice(0, 5)
.map(p => ({ id: p.id, nome: p.nome, sub: '', avatar_url: null }));
});
const filteredEventos = computed(() => {
@@ -118,6 +169,9 @@ const rpcIntakes = computed(() => rpcResults.value.intakes || []);
const flatList = computed(() => {
const out = [];
// "Ir para [data]" sempre no topo quando query parseia como data
// acao predominante (Enter direto seleciona ela).
if (dateMatch.value) out.push({ group: 'goto-date', item: dateMatch.value, idx: 0 });
recentItems.value.forEach((p, i) => out.push({ group: 'recent', item: p, idx: i }));
filteredAtalhos.value.forEach((a, i) => out.push({ group: 'atalhos', item: a, idx: i }));
filteredPacientes.value.forEach((p, i) => out.push({ group: 'pacientes', item: p, idx: i }));
@@ -135,13 +189,22 @@ function findFlatIndex(group, idx) {
}
function selectEntry(entry) {
if (entry.group === 'atalhos') emit('acao', entry.item.id);
if (entry.group === 'goto-date') emit('goto-date', entry.item);
else if (entry.group === 'atalhos') emit('acao', entry.item.id);
else if (entry.group === 'pacientes') emit('paciente', entry.item);
else if (entry.group === 'recent') emit('paciente', { id: entry.item.id, nome: entry.item.nome, ...entry.item.extras });
else if (entry.group === 'eventos') emit('evento', entry.item);
else if (entry.group === 'rpc-appointments') emit('evento', entry.item);
else if (entry.group === 'rpc-documents') emit('documento', entry.item);
else if (entry.group === 'rpc-intakes') emit('intake', entry.item);
else if (entry.group === 'rpc-appointments') {
// Sessão da RPC: deeplink pra agenda com evento focado
emit('evento', { id: entry.item.id, deeplink: entry.item.deeplink });
} else if (entry.group === 'rpc-documents') {
// Documento da RPC: extrai patient_id da deeplink se possível
const dl = entry.item.deeplink || '';
const m = dl.match(/patients\/([0-9a-f-]+)/i);
emit('documento', { id: entry.item.id, patient_id: m?.[1] || null, label: entry.item.label });
} else if (entry.group === 'rpc-intakes') {
emit('intake', entry.item);
}
closePanel();
}
@@ -149,44 +212,41 @@ function closePanel() {
showPanel.value = false;
query.value = '';
activeIndex.value = -1;
inputEl.value?.blur();
}
function onFocus() {
function openDialog() {
showPanel.value = true;
}
function onClickOutside(e) {
if (rootEl.value && !rootEl.value.contains(e.target)) {
showPanel.value = false;
activeIndex.value = -1;
}
// Foca input do Dialog após ele montar
nextTick(() => inputEl.value?.focus());
}
function onKeydown(e) {
if (!showPanel.value) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex.value = Math.min(activeIndex.value + 1, flatList.value.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex.value = Math.max(activeIndex.value - 1, 0);
} else if (e.key === 'Enter' && activeIndex.value >= 0) {
e.preventDefault();
selectEntry(flatList.value[activeIndex.value]);
} else if (e.key === 'Escape') {
// Stop bubbling pra ESC do parent não fechar overlay aleatório
e.stopPropagation();
closePanel();
} else if (e.key === 'Enter') {
// Enter sem selecao explicita: pega o primeiro item do flatList
// (UX spotlight padrao usuario digita "hoje" + Enter deve ir
// direto pra hoje sem precisar ArrowDown).
if (activeIndex.value >= 0) {
e.preventDefault();
selectEntry(flatList.value[activeIndex.value]);
} else if (flatList.value.length > 0) {
e.preventDefault();
selectEntry(flatList.value[0]);
}
}
// Escape é tratado pelo Dialog (dismissableMask + closable)
}
function onGlobalKeydown(e) {
// Ctrl+K / +K foca input
// Ctrl+K / +K abre dialog
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
showPanel.value = true;
inputEl.value?.focus();
openDialog();
}
}
@@ -205,6 +265,14 @@ watch(query, (v) => {
searching.value = false;
return;
}
// Query parseou como data: pula RPC (nao faz sentido buscar paciente
// chamado "20/06"). Card "Ir para data" cobre o caso sozinho.
if (dateMatch.value) {
++searchSeq; // invalida requests em flight
resetRpcResults();
searching.value = false;
return;
}
searching.value = true;
const mySeq = ++searchSeq;
debounceT = setTimeout(async () => {
@@ -234,34 +302,61 @@ watch(query, (v) => {
});
onMounted(() => {
document.addEventListener('mousedown', onClickOutside);
window.addEventListener('keydown', onGlobalKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onClickOutside);
window.removeEventListener('keydown', onGlobalKeydown);
if (debounceT) clearTimeout(debounceT);
});
// Exposto pro MelissaLayout a lupa unica na .melissa-tray chama
// melissaBuscaRef.openDialog() direto, e o provide('openMelissaBusca')
// reusa o mesmo metodo pra qualquer descendente que queira abrir o
// spotlight programaticamente. closeDialog alias do closePanel.
defineExpose({ openDialog, closeDialog: closePanel });
</script>
<template>
<div ref="rootEl" class="mb-search">
<div class="mb-field">
<!-- Trigger: aparência de input, mas é botão (abre Dialog) -->
<button type="button" class="mb-field" @click="openDialog" :aria-label="'Buscar (Ctrl+K)'">
<i class="pi pi-search mb-field__icon" />
<input
ref="inputEl"
v-model="query"
type="text"
placeholder="Buscar paciente, agenda, atalho…"
class="mb-field__input"
@focus="onFocus"
@keydown="onKeydown"
/>
<span class="mb-field__placeholder">Buscar paciente, agenda, atalho</span>
<span class="mb-field__kbd" aria-hidden="true">Ctrl K</span>
</div>
</button>
<Transition name="mb-fade">
<div v-if="showPanel" class="mb-panel" role="listbox">
<!-- Dialog Spotlight: input grande no topo + resultados em coluna -->
<Dialog
v-model:visible="showPanel"
modal
:draggable="false"
:closable="false"
:dismissableMask="true"
:showHeader="false"
class="mb-dialog"
:style="{ width: '640px', maxWidth: '94vw' }"
pt:mask:class="mb-dialog__mask"
pt:content:class="mb-dialog__content"
@hide="closePanel"
>
<!-- Field do Dialog (input real, autofocus) -->
<div class="mb-dialog__field">
<i class="pi pi-search mb-dialog__field-icon" />
<input
ref="inputEl"
v-model="query"
type="text"
placeholder="Buscar paciente, agenda, atalho…"
class="mb-dialog__input"
@keydown="onKeydown"
autocomplete="off"
spellcheck="false"
/>
<span class="mb-dialog__esc" aria-hidden="true">Esc</span>
</div>
<!-- Painel de resultados (scroll interno) -->
<div class="mb-panel" role="listbox">
<div
v-if="query.trim().length >= 2 && !hasAnyResult"
class="mb-empty"
@@ -269,6 +364,25 @@ onBeforeUnmount(() => {
Nada encontrado pra "<span class="text-white/80">{{ query.trim() }}</span>"
</div>
<!-- "Ir para [data]" quando query parseia como data
(hoje/amanha/ontem/DD/MM/YYYY). Predominante: vai pra
primeira linha do flatList e Enter direto seleciona. -->
<div v-if="dateMatch" class="mb-group">
<button
class="mb-item mb-item--gotodate"
:class="{ 'is-active': findFlatIndex('goto-date', 0) === activeIndex }"
@click="selectEntry({ group: 'goto-date', item: dateMatch })"
@mouseenter="activeIndex = findFlatIndex('goto-date', 0)"
>
<span class="mb-item__icon mb-item__icon--gotodate"><i class="pi pi-calendar" /></span>
<span class="mb-item__main">
<span class="mb-item__label">Ir para {{ fmtDataLonga(dateMatch) }}</span>
<span class="mb-item__sub">Pular para essa data no calendário</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
<!-- Acessados recentemente ( quando query vazia) -->
<div v-if="showRecent" class="mb-group">
<div class="mb-group__title">Acessados recentemente</div>
@@ -320,10 +434,10 @@ onBeforeUnmount(() => {
@click="selectEntry({ group: 'pacientes', item: p })"
@mouseenter="activeIndex = findFlatIndex('pacientes', i)"
>
<span class="mb-item__icon"><i class="pi pi-user" /></span>
<span class="mb-item__icon mb-item__icon--patient"><i class="pi pi-user" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ p.nome }}</span>
<span class="mb-item__sub">Abrir prontuário</span>
<span class="mb-item__sub">{{ p.sub || 'Abrir prontuário' }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
@@ -354,7 +468,10 @@ onBeforeUnmount(() => {
</button>
</div>
<!-- RPC: Sessões/agendamentos (qualquer data) -->
<!-- RPC: Sessões/agendamentos (qualquer data)
RPC retorna { id, label, sublabel, deeplink }. Sublabel ja vem
com "Paciente · dd/mm/yyyy HH:MM". Cor do icone = cor de sessao
(indigo-500, igual ao pickColor() padrao). -->
<div v-if="rpcAppointments.length" class="mb-group">
<div class="mb-group__title">Sessões</div>
<button
@@ -365,10 +482,10 @@ onBeforeUnmount(() => {
@click="selectEntry({ group: 'rpc-appointments', item: e })"
@mouseenter="activeIndex = findFlatIndex('rpc-appointments', i)"
>
<span class="mb-item__icon"><i class="pi pi-calendar" /></span>
<span class="mb-item__icon mb-item__icon--sessao"><i class="pi pi-calendar" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ e.paciente_nome || e.title || 'Sessão' }}</span>
<span class="mb-item__sub">{{ e.inicio_em ? new Date(e.inicio_em).toLocaleDateString('pt-BR') + ' ' + new Date(e.inicio_em).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : 'Sem data' }}</span>
<span class="mb-item__label">{{ e.label || 'Sessão' }}</span>
<span class="mb-item__sub">{{ e.sublabel || 'Sem detalhes' }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
@@ -385,10 +502,10 @@ onBeforeUnmount(() => {
@click="selectEntry({ group: 'rpc-documents', item: d })"
@mouseenter="activeIndex = findFlatIndex('rpc-documents', i)"
>
<span class="mb-item__icon"><i class="pi pi-file" /></span>
<span class="mb-item__icon mb-item__icon--doc"><i class="pi pi-file" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ d.nome_original || 'Documento' }}</span>
<span class="mb-item__sub">{{ d.paciente_nome ? `${d.paciente_nome} · ` : '' }}{{ d.tipo_documento || 'outro' }}</span>
<span class="mb-item__label">{{ d.label || 'Documento' }}</span>
<span class="mb-item__sub">{{ d.sublabel || '' }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
@@ -405,16 +522,16 @@ onBeforeUnmount(() => {
@click="selectEntry({ group: 'rpc-intakes', item: r })"
@mouseenter="activeIndex = findFlatIndex('rpc-intakes', i)"
>
<span class="mb-item__icon"><i class="pi pi-inbox" /></span>
<span class="mb-item__icon mb-item__icon--intake"><i class="pi pi-inbox" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ r.nome_completo || 'Cadastro' }}</span>
<span class="mb-item__sub">{{ r.created_at ? new Date(r.created_at).toLocaleDateString('pt-BR') : '' }}</span>
<span class="mb-item__label">{{ r.label || 'Cadastro' }}</span>
<span class="mb-item__sub">{{ r.sublabel || '' }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
</div>
</Transition>
</Dialog>
</div>
</template>
@@ -423,16 +540,17 @@ onBeforeUnmount(() => {
position: relative;
width: 100%;
max-width: 560px;
/* Só horizontal — top/bottom ficam livres pro parent (mt-X do Tailwind) */
margin-left: auto;
margin-right: auto;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
/* ─── Trigger no dock (visual de input, mas é botão que abre Dialog) ─── */
.mb-field {
position: relative;
display: flex;
align-items: center;
width: 100%;
background: var(--m-bg-soft);
backdrop-filter: blur(20px) saturate(140%);
-webkit-backdrop-filter: blur(20px) saturate(140%);
@@ -440,11 +558,12 @@ onBeforeUnmount(() => {
border-radius: 12px;
padding: 0 14px;
height: 44px;
cursor: pointer;
text-align: left;
transition: background-color 160ms ease, border-color 160ms ease;
}
.mb-field:focus-within {
.mb-field:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mb-field__icon {
color: var(--m-text-muted);
@@ -452,18 +571,15 @@ onBeforeUnmount(() => {
margin-right: 10px;
flex-shrink: 0;
}
.mb-field__input {
.mb-field__placeholder {
flex: 1;
background: transparent;
border: none;
outline: none;
color: white;
color: var(--m-text-muted);
font-size: 0.9rem;
font-family: inherit;
min-width: 0;
}
.mb-field__input::placeholder {
color: var(--m-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mb-field__kbd {
color: var(--m-text-muted);
@@ -478,49 +594,109 @@ onBeforeUnmount(() => {
letter-spacing: 0.05em;
}
/* ─── Dialog Spotlight (PrimeVue Dialog customizado) ─── */
:global(.mb-dialog__mask) {
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
background: rgba(0, 0, 0, 0.55) !important;
}
:global(.mb-dialog) {
border-radius: 14px !important;
overflow: hidden;
/* Posiciona mais alto que o centro (estilo Spotlight) */
margin-top: 10vh !important;
align-self: flex-start;
}
:global(.mb-dialog .mb-dialog__content) {
padding: 0 !important;
background: var(--surface-card) !important;
border-radius: 14px !important;
overflow: hidden;
max-height: 70vh;
display: flex;
flex-direction: column;
}
.mb-dialog__field {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-card);
flex-shrink: 0;
}
.mb-dialog__field-icon {
color: var(--text-color-secondary);
font-size: 1.05rem;
flex-shrink: 0;
}
.mb-dialog__input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text-color);
font-size: 1.05rem;
font-family: inherit;
min-width: 0;
}
.mb-dialog__input::placeholder {
color: var(--text-color-secondary);
opacity: 0.7;
}
.mb-dialog__esc {
color: var(--text-color-secondary);
font-size: 0.65rem;
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
background: var(--surface-ground);
border: 1px solid var(--surface-border);
flex-shrink: 0;
letter-spacing: 0.05em;
opacity: 0.8;
}
/* Panel agora vive DENTRO do Dialog (não mais absolute). Scroll interno
resolve o bug de overflow o conteúdo nunca passa do bottom porque
o Dialog tem max-height e o panel é flex:1. */
.mb-panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 30;
max-height: 60vh;
flex: 1 1 auto;
overflow-y: auto;
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border: 1px solid var(--m-border-strong);
border-radius: 12px;
overflow-x: hidden;
padding: 6px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
background: var(--surface-card);
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
scrollbar-color: var(--surface-border) transparent;
min-height: 0; /* permite shrink no flex */
}
.mb-panel::-webkit-scrollbar { width: 6px; }
.mb-panel::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
background: var(--surface-border);
border-radius: 3px;
}
.mb-empty {
padding: 18px 14px;
text-align: center;
color: var(--m-text-muted);
color: var(--text-color-secondary);
font-size: 0.85rem;
}
.mb-group + .mb-group {
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--m-border);
border-top: 1px solid var(--surface-border);
}
.mb-group__title {
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--m-text-faint);
font-size: 0.6rem;
font-weight: 600;
color: var(--text-color-secondary);
font-size: 0.62rem;
font-weight: 700;
padding: 8px 10px 4px;
opacity: 0.75;
}
.mb-item {
@@ -528,11 +704,11 @@ onBeforeUnmount(() => {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
padding: 9px 10px;
background: transparent;
border: none;
border-radius: 8px;
color: white;
color: var(--text-color);
text-align: left;
cursor: pointer;
font-family: inherit;
@@ -540,53 +716,89 @@ onBeforeUnmount(() => {
}
.mb-item:hover,
.mb-item.is-active {
background: var(--m-bg-soft);
background: color-mix(in srgb, var(--p-primary-color) 8%, transparent);
}
.mb-item__icon {
width: 30px;
height: 30px;
width: 32px;
height: 32px;
display: grid;
place-items: center;
background: var(--m-bg-soft);
background: var(--surface-ground);
border-radius: 7px;
color: var(--m-text-muted);
color: var(--text-color-secondary);
flex-shrink: 0;
font-size: 0.85rem;
font-size: 0.9rem;
}
/* Cores por tipo — semântica fixa (não depende do tema, é categoria). */
.mb-item__icon--patient {
background: rgba(244, 114, 182, 0.18);
color: #ec4899;
}
.mb-item__icon--sessao {
background: rgba(99, 102, 241, 0.20);
color: #6366f1;
}
.mb-item__icon--doc {
background: rgba(14, 165, 233, 0.18);
color: #0ea5e9;
}
.mb-item__icon--intake {
background: rgba(251, 146, 60, 0.18);
color: #f97316;
}
/* Dark mode: clareia as cores semânticas pra manter contraste */
:root.app-dark .mb-item__icon--patient { color: #f9a8d4; }
:root.app-dark .mb-item__icon--sessao { color: #a5b4fc; }
:root.app-dark .mb-item__icon--doc { color: #7dd3fc; }
:root.app-dark .mb-item__icon--intake { color: #fdba74; }
/* "Ir para [data]" card azul predominante, mesmo padrao visual do
popover da agenda (MelissaAgendaSearchPopover). Cores diretas (sem
var/color-mix) pra garantir contraste em ambos os modos. */
.mb-item--gotodate {
background: rgba(59, 130, 246, 0.16);
border: 1.5px solid rgba(59, 130, 246, 0.55);
}
.mb-item--gotodate:hover,
.mb-item--gotodate.is-active {
background: rgba(59, 130, 246, 0.26);
border-color: rgba(59, 130, 246, 0.75);
}
.mb-item__icon--gotodate {
background: #3b82f6;
color: white;
}
.mb-item--gotodate .mb-item__label { color: #2563eb; font-weight: 600; }
:root.app-dark .mb-item--gotodate .mb-item__label { color: #93c5fd; }
:root.app-dark .mb-item--gotodate .mb-item__sub { color: rgba(147, 197, 253, 0.7); }
.mb-item__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
gap: 2px;
}
.mb-item__label {
font-size: 0.85rem;
color: white;
font-size: 0.88rem;
font-weight: 500;
color: var(--text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mb-item__sub {
font-size: 0.7rem;
color: var(--m-text-muted);
font-size: 0.74rem;
color: var(--text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mb-item__go {
color: var(--m-text-faint);
font-size: 0.7rem;
color: var(--text-color-secondary);
opacity: 0.5;
font-size: 0.75rem;
flex-shrink: 0;
}
.mb-fade-enter-active,
.mb-fade-leave-active {
transition: opacity 140ms ease, transform 160ms ease;
}
.mb-fade-enter-from,
.mb-fade-leave-to {
opacity: 0;
transform: translateY(-6px);
}
</style>
+175 -7
View File
@@ -5,7 +5,8 @@
* Cronômetro de sessão estilo "janela do Windows":
* - Dialog centralizado com select de paciente, display gigante e ações
* - Click fora minimiza (chip no canto superior esquerdo)
* - X/ESC fecha (destrói)
* - X = encerrar sem salvar. Com confirmacao se houver sessao em
* andamento ou tempo decorrido (fechar limpo nao pede confirm)
* - Botão inferior alterna entre "▶ Começar" e "⏹ Parar"
* - "+1 minuto" estende o tempo
* - Quando minimizado, o timer continua rodando em background
@@ -23,8 +24,11 @@
* cronoRef.value.fechar() // destrói
*/
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { useConfirm } from 'primevue/useconfirm';
import { playToque } from './melissaToques';
const confirm = useConfirm();
const STORAGE_KEY = 'melissa.cronometro.v1';
const props = defineProps({
@@ -57,6 +61,16 @@ const seconds = ref(props.duracaoMinutos * 60);
const pacienteId = ref(props.defaultPacienteId);
let timer = null;
// Plano da sessao (horario programado original do evento na agenda).
// Setado quando o cronometro abre a partir de um evento da timeline
// vide abrir({ sessionPlan: { startH, endH } }). Null quando aberto
// manualmente. Persiste em localStorage junto com o resto do snap.
const sessionPlan = ref(null); // { startH: number, endH: number } | null
// Tick a cada 30s pra recomputar atraso conforme o tempo passa (so
// quando cronometro existe; reseta no fechar).
const _planNowTick = ref(Date.now());
let _planNowTimer = null;
// True só durante a transição de minimizar (dialog chip).
// Permite trocar a animação leave do dialog por "shrink + fly to dock"
// (tipo macOS minimize) em vez do lift normal usado pra fechar.
@@ -90,21 +104,86 @@ const pacienteNome = computed(() => {
return p ? p.nome : '';
});
// Formatador hh:mm a partir do startH decimal (ex: 11.5 "11:30").
function _fmtHora(h) {
if (typeof h !== 'number' || Number.isNaN(h)) return '';
const hh = Math.floor(h);
const mm = Math.round((h - hh) * 60);
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
}
const sessionPlanLabel = computed(() => {
const p = sessionPlan.value;
if (!p || typeof p.startH !== 'number' || typeof p.endH !== 'number') return '';
return `Programado: ${_fmtHora(p.startH)} ${_fmtHora(p.endH)}`;
});
// Atraso em minutos vs horario programado. Calcula em relacao ao
// _planNowTick (atualizado a cada 30s) pra nao recomputar a cada frame.
const atrasoMin = computed(() => {
const p = sessionPlan.value;
if (!p || typeof p.startH !== 'number') return 0;
// ref usada so pra forcar recompute periodico
void _planNowTick.value;
const d = new Date();
const hNow = d.getHours() + d.getMinutes() / 60;
const diff = hNow - p.startH;
if (diff <= 0) return 0;
return Math.round(diff * 60);
});
// Watch: avisa parent quando dialog aparece/some
watch(visible, (v) => emit('visible-change', v));
// Ações
function abrir() {
// abrir({ pacienteId, autostart }) opts permitem pre-selecionar
// um paciente (ex: click no botao "iniciar sessao" da timeline) e
// auto-iniciar a contagem. Sem opts mantem comportamento legado.
// Retorna { opened, alreadyRunning, pacienteId } pra caller decidir
// (ex: mostrar toast se ja tem cronometro rodando de outro paciente).
function abrir(opts = {}) {
const requestedPid = Object.prototype.hasOwnProperty.call(opts, 'pacienteId')
? opts.pacienteId
: props.defaultPacienteId;
if (exists.value) {
// Já existe apenas restaura se tava minimizado (não cria outro)
// Ja existe comportamento opcao (b): nao troca paciente. Apenas
// restaura visualmente se estava minimizado. Caller decide se
// mostra toast quando o paciente requisitado e' diferente do atual.
if (minimized.value) minimized.value = false;
return;
return {
opened: false,
alreadyRunning: !!running.value,
pacienteId: pacienteId.value,
samePaciente: pacienteId.value === requestedPid
};
}
seconds.value = props.duracaoMinutos * 60;
pacienteId.value = props.defaultPacienteId;
pacienteId.value = requestedPid;
running.value = false;
minimized.value = false;
exists.value = true;
// Plano programado (vem do evento da timeline) usado pra exibir
// "Programado: HH:MM HH:MM" e badge de atraso. Sanitiza pra
// garantir 2 numeros validos; senao limpa.
const plan = opts.sessionPlan;
if (plan && typeof plan.startH === 'number' && typeof plan.endH === 'number'
&& !Number.isNaN(plan.startH) && !Number.isNaN(plan.endH)) {
sessionPlan.value = { startH: plan.startH, endH: plan.endH };
} else {
sessionPlan.value = null;
}
if (opts.autostart) {
// Defer pra rodar dps do mount/render do dialog (toggle precisa
// do setInterval em proximo tick pra contar a partir do segundo
// cheio, nao perde a fracao do tick atual).
setTimeout(() => { if (exists.value && !running.value) toggle(); }, 0);
}
return {
opened: true,
alreadyRunning: false,
pacienteId: pacienteId.value,
samePaciente: true
};
}
function toggle() {
@@ -161,9 +240,33 @@ function fechar() {
running.value = false;
minimized.value = false;
exists.value = false;
sessionPlan.value = null; // limpa pra proxima abertura comecar zerada
emit('close');
}
// Fechar com confirmacao quando ha sessao em andamento ou tempo
// decorrido sem salvar. Estado "clean" (parado + nada decorrido)
// fecha direto pra nao atrapalhar quem abriu por engano.
function confirmarFechar() {
const totalInicial = props.duracaoMinutos * 60;
const temAtividade = running.value || seconds.value !== totalInicial;
if (!temAtividade) {
fechar();
return;
}
confirm.require({
message: running.value
? 'Sessão em andamento — encerrar agora descarta o tempo cronometrado sem salvar no DB.'
: 'O cronômetro tem tempo decorrido que ainda não foi salvo. Quer descartar?',
header: 'Encerrar sessão sem salvar?',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Encerrar sem salvar',
rejectLabel: 'Continuar sessão',
acceptClass: 'p-button-danger',
accept: () => fechar()
});
}
function ajustarMinutos(delta) {
seconds.value += delta * 60;
// Re-baselina savedAt pra restore após reload pegar o novo valor correto
@@ -185,7 +288,8 @@ function saveState() {
minimized: !!minimized.value,
running: !!running.value,
seconds: seconds.value,
savedAt: Date.now()
savedAt: Date.now(),
sessionPlan: sessionPlan.value // null | { startH, endH }
};
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(snap)); } catch {}
}
@@ -223,6 +327,15 @@ function loadState() {
seconds.value = restoredSeconds;
exists.value = true;
running.value = false; // toggle abaixo flipa pra true
// Restaura plano programado se foi serializado. Sanitiza shape:
// {startH:number, endH:number} valido ou null.
const sp = snap.sessionPlan;
if (sp && typeof sp.startH === 'number' && typeof sp.endH === 'number'
&& !Number.isNaN(sp.startH) && !Number.isNaN(sp.endH)) {
sessionPlan.value = { startH: sp.startH, endH: sp.endH };
} else {
sessionPlan.value = null;
}
if (wasRunning) {
// Retoma o interval. NÃO toca o toque retroativo se o tempo
@@ -235,6 +348,19 @@ function loadState() {
// Watch nas mudanças de estado discreto (não em seconds enquanto roda savedAt+delta dá conta)
watch([exists, minimized, running, pacienteId], () => saveState());
// Tick a cada 30s pra recomputar atraso. So roda quando o cronometro
// existe E tem plano programado senao desperdicio.
watch([exists, sessionPlan], ([e, p]) => {
if (e && p) {
if (!_planNowTimer) {
_planNowTimer = setInterval(() => { _planNowTick.value = Date.now(); }, 30_000);
}
} else if (_planNowTimer) {
clearInterval(_planNowTimer);
_planNowTimer = null;
}
});
// Mount / Cleanup
onMounted(() => {
loadState();
@@ -242,6 +368,7 @@ onMounted(() => {
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
if (_planNowTimer) clearInterval(_planNowTimer);
});
defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible });
@@ -264,7 +391,7 @@ defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible });
<button class="mc-glass-btn" title="Minimizar" @click="minimizar">
<i class="pi pi-window-minimize text-white/90 text-xs" />
</button>
<button class="mc-glass-btn" title="Fechar" @click="fechar">
<button class="mc-glass-btn" title="Encerrar sem salvar" @click="confirmarFechar">
<i class="pi pi-times text-white/90 text-sm" />
</button>
</div>
@@ -284,6 +411,17 @@ defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible });
</select>
<i class="pi pi-chevron-down mc-select-icon" />
</div>
<!-- Plano programado da sessao (so quando aberto via
evento da timeline). Mostra horario original +
badge de atraso se aplicavel analista decide,
cronometro nao auto-ajusta. -->
<div v-if="sessionPlanLabel" class="mc-session-plan">
<i class="pi pi-calendar text-white/55 text-[0.7rem]" />
<span class="text-white/70 text-[0.78rem]">{{ sessionPlanLabel }}</span>
<span v-if="atrasoMin > 0" class="mc-session-plan__late">
atrasada {{ atrasoMin }} min
</span>
</div>
</div>
<!-- Display gigante + steppers manuais (+5 / -5) -->
@@ -436,6 +574,28 @@ defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible });
pointer-events: none;
}
/* Plano programado linha abaixo do select com horario original e
badge de atraso quando aplicavel. Tom secundario pra nao roubar
atencao do display gigante do cronometro. */
.mc-session-plan {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 4px 0;
}
.mc-session-plan__late {
margin-left: 4px;
padding: 1px 8px;
border-radius: 9999px;
background: rgba(251, 146, 60, 0.18);
color: rgb(253, 186, 116);
font-size: 0.7rem;
font-weight: 500;
line-height: 1.4;
border: 1px solid rgba(251, 146, 60, 0.35);
}
/* ─── Display gigante ──────────────────────────────────────── */
.mc-display {
font-size: 5rem;
@@ -567,6 +727,14 @@ defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible });
padding-left: 6px;
border-left: 1px solid var(--m-border-strong);
}
/* Em mobile (<md=768px) o chip vive num dock estreito que precisa
acomodar 4 builtins + ψ + tray. Esconde o nome do paciente o
icone + timer ja sinalizam o estado, e o nome continua disponivel
ao restaurar o cronometro (click). */
@media (max-width: 767px) {
.mc-chip-name { display: none; }
.mc-chip { padding: 8px 12px; }
}
.mc-chip-pulse {
animation: mc-pulse 1.6s ease-in-out infinite;
}
+716 -127
View File
@@ -9,7 +9,7 @@
* Lógica idêntica à DocumentTemplatesPage (composable
* useDocumentTemplates + DocumentTemplateEditor reusado).
*/
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
@@ -17,6 +17,7 @@ import { useDocumentTemplates } from '@/features/documents/composables/useDocume
import DocumentTemplateEditor from '@/features/documents/components/DocumentTemplateEditor.vue';
// Button/Menu/Skeleton: auto via PrimeVueResolver
import Menu from 'primevue/menu';
import DataView from 'primevue/dataview';
const emit = defineEmits(['close']);
const toast = useToast();
@@ -29,11 +30,15 @@ const {
fetchTemplates, create, update, remove, duplicate
} = useDocumentTemplates();
// Views
// Views: list | create | edit | preview
const view = ref('list');
const editingTemplate = ref({});
const editingId = ref(null);
// Preview de template global (somente leitura) abre antes de duplicar
// para o usuário ler o conteúdo. Inclui botão "Duplicar" no header.
const previewTemplate = ref(null);
// Acoes
function openCreate() {
editingId.value = null;
@@ -56,6 +61,58 @@ function openEdit(tpl) {
view.value = 'edit';
}
// Preview de template do sistema leitura + botão Duplicar.
// Clique na sidebar de templates do sistema cai aqui em vez de
// duplicar direto.
function openPreview(tpl) {
previewTemplate.value = tpl;
view.value = 'preview';
// No mobile, fecha o drawer pra dar espaço ao preview
if (isMobile.value) drawerOpen.value = false;
}
// Monta HTML completo do template (cabeçalho + corpo + rodapé) com
// estilos básicos pra preview legível dentro do iframe.
const previewHtml = computed(() => {
const tpl = previewTemplate.value;
if (!tpl) return '';
const cabecalho = tpl.cabecalho_html || '';
const corpo = tpl.corpo_html || '';
const rodape = tpl.rodape_html || '';
return `<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<style>
@page { size: A4; margin: 20mm 15mm 25mm 15mm; }
body {
font-family: 'Segoe UI', Arial, sans-serif;
font-size: 12pt;
line-height: 1.6;
color: #1a1a1a;
background: #ffffff;
padding: 32px;
margin: 0;
}
h2 { font-size: 16pt; margin-bottom: 16px; }
h3 { font-size: 13pt; margin-top: 20px; margin-bottom: 8px; }
p { margin: 8px 0; }
table { border-collapse: collapse; width: 100%; }
td { padding: 4px 8px; }
hr { border: none; border-top: 1px solid #333; }
ul, ol { margin: 8px 0; padding-left: 24px; }
.doc-header { text-align: center; margin-bottom: 24px; padding-bottom: 12px; border-bottom: 1px solid #ccc; }
.doc-footer { margin-top: 40px; padding-top: 12px; border-top: 1px solid #ccc; font-size: 10pt; color: #666; text-align: center; }
</style>
</head>
<body>
${cabecalho ? `<div class="doc-header">${cabecalho}</div>` : ''}
<div class="doc-content">${corpo}</div>
${rodape ? `<div class="doc-footer">${rodape}</div>` : ''}
</body>
</html>`;
});
async function onSave(payload) {
try {
if (view.value === 'create') {
@@ -82,7 +139,12 @@ function onDuplicate(tpl) {
accept: async () => {
try {
await duplicate(tpl.id);
toast.add({ severity: 'success', summary: 'Duplicado', detail: `"${tpl.nome_template}" copiado para Meus Templates.`, life: 3000 });
toast.add({ severity: 'success', summary: 'Duplicado', detail: `"${tpl.nome_template}" copiado para Seus documentos.`, life: 3000 });
// Se veio do preview, volta pra list pra mostrar o novo template no main
if (view.value === 'preview') {
view.value = 'list';
previewTemplate.value = null;
}
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message });
}
@@ -114,6 +176,15 @@ function tipoLabel(tipo) {
return TIPOS_TEMPLATE.find((t) => t.value === tipo)?.label || tipo;
}
// Formata as variáveis do template como string "{{nome}}, {{outra}}"
// (helper externo evita conflito de {{ }} aninhado no template Vue)
function formatVarsPreview(vars, max = 5) {
if (!Array.isArray(vars) || !vars.length) return '';
const open = '{';
const close = '}';
return vars.slice(0, max).map((v) => `${open}${open}${v}${close}${close}`).join(', ');
}
// Card menu (templates do tenant)
function getCardMenuItems(tpl) {
const items = [
@@ -129,16 +200,69 @@ function getCardMenuItems(tpl) {
return items;
}
// Mobile drawer (espelha padrão MelissaBloqueios)
const drawerOpen = ref(false);
const isMobile = ref(false);
let _mqMobile = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false;
}
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
onMounted(() => {
fetchTemplates(true);
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
isMobile.value = _mqMobile.matches;
try { _mqMobile.addEventListener('change', _onMqMobileChange); }
catch { _mqMobile.addListener(_onMqMobileChange); }
}
});
onBeforeUnmount(() => {
if (_mqMobile) {
try { _mqMobile.removeEventListener('change', _onMqMobileChange); }
catch { _mqMobile.removeListener(_onMqMobileChange); }
}
});
</script>
<template>
<ConfirmDialog />
<!-- Mobile drawer (templates do sistema) -->
<Transition name="mdt-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="mdt-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
>
<div id="mdt-mobile-drawer-target" class="mdt-mobile-drawer__scroll" />
</div>
</Transition>
<Transition name="mdt-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="mdt-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<!-- ConfirmDialog global mora no MelissaLayout montar aqui causava
callback duplicado (dois listeners pro mesmo require) -->
<section class="mdt-page">
<header class="mdt-page__head">
<!-- Botão "Menu" mobile-only (vira título no mobile, abre drawer com templates do sistema) -->
<button
v-if="view === 'list' || view === 'preview'"
class="mdt-menu-btn mdt-menu-btn--mobile-only"
v-tooltip.bottom="'Templates do sistema'"
@click="toggleDrawer"
>
<i class="pi pi-bars" />
<span>Templates do sistema</span>
</button>
<div class="mdt-page__title">
<button
v-if="view !== 'list'"
@@ -152,7 +276,8 @@ onMounted(() => {
<span>
<template v-if="view === 'list'">Templates de documentos</template>
<template v-else-if="view === 'create'">Novo template</template>
<template v-else>Editar template</template>
<template v-else-if="view === 'edit'">Editar template</template>
<template v-else-if="view === 'preview'">Visualizar template</template>
</span>
<span v-if="view === 'list'" class="mdt-page__count">{{ templates.length }}</span>
</div>
@@ -210,8 +335,8 @@ onMounted(() => {
<!-- Body -->
<div class="mdt-body">
<!-- LIST VIEW -->
<template v-if="view === 'list'">
<!-- LIST + PREVIEW VIEWS sidebar do sistema sempre presente -->
<template v-if="view === 'list' || view === 'preview'">
<!-- Loading -->
<div v-if="loading && !templates.length" class="mdt-loading">
<i class="pi pi-spin pi-spinner" />
@@ -229,103 +354,183 @@ onMounted(() => {
</button>
</div>
<template v-else>
<!-- Templates globais (padrão) -->
<div v-if="globalTemplates.length" class="mdt-section">
<div class="mdt-section__head">
<div class="mdt-section__title">
<!-- Layout 2-col: sidebar (globais) + main (do tenant) -->
<div v-else class="mdt-cols">
<!-- COL 1 Sidebar: Templates do sistema (teleporta pro drawer no mobile) -->
<Teleport to="#mdt-mobile-drawer-target" :disabled="!isMobile">
<aside class="mdt-side">
<header class="mdt-side__head">
<div class="mdt-side__title">
<i class="pi pi-shield" />
<span>Templates padrão do sistema</span>
<span>Templates do sistema</span>
</div>
<span class="mdt-section__count is-info">{{ globalTemplates.length }}</span>
<span class="mdt-page__count">{{ globalTemplates.length }}</span>
</header>
<p class="mdt-side__subtitle">Modelos padrão da plataforma. Clique pra duplicar e personalizar.</p>
<div v-if="!globalTemplates.length" class="mdt-side__empty">
<i class="pi pi-info-circle" />
<span>Sem templates do sistema disponíveis.</span>
</div>
<div class="mdt-grid">
<button
<ul v-else class="mdt-side__list">
<li
v-for="tpl in globalTemplates"
:key="tpl.id"
class="mdt-card mdt-card--global"
type="button"
@click="onDuplicate(tpl)"
>
<div class="mdt-card__head">
<span class="mdt-card__icon mdt-card__icon--info">
<i class="pi pi-file" />
</span>
<div class="mdt-card__main">
<div class="mdt-card__name">{{ tpl.nome_template }}</div>
<div class="mdt-card__tipo">{{ tipoLabel(tpl.tipo) }}</div>
<div v-if="tpl.descricao" class="mdt-card__desc">{{ tpl.descricao }}</div>
</div>
</div>
<span class="mdt-card__badge mdt-card__badge--info">padrão</span>
<div class="mdt-card__hint">
<i class="pi pi-copy" />
Click pra duplicar e personalizar
</div>
</button>
</div>
</div>
<!-- Templates do tenant (meus) -->
<div v-if="tenantTemplates.length" class="mdt-section">
<div class="mdt-section__head">
<div class="mdt-section__title">
<i class="pi pi-user-edit" />
<span>Meus templates</span>
</div>
<span class="mdt-section__count is-accent">{{ tenantTemplates.length }}</span>
</div>
<div class="mdt-grid">
<div
v-for="tpl in tenantTemplates"
:key="tpl.id"
class="mdt-card mdt-card--tenant"
class="mdt-side__item"
:class="{ 'is-active': view === 'preview' && previewTemplate?.id === tpl.id }"
role="button"
tabindex="0"
@click="openEdit(tpl)"
@keydown.enter.prevent="openEdit(tpl)"
@click="openPreview(tpl)"
@keydown.enter.prevent="openPreview(tpl)"
>
<div class="mdt-card__head">
<span class="mdt-card__icon mdt-card__icon--primary">
<i class="pi pi-file-edit" />
</span>
<div class="mdt-card__main">
<div class="mdt-card__name">{{ tpl.nome_template }}</div>
<div class="mdt-card__tipo">{{ tipoLabel(tpl.tipo) }}</div>
<div v-if="tpl.descricao" class="mdt-card__desc">{{ tpl.descricao }}</div>
<span class="mdt-side__item-icon">
<i class="pi pi-file" />
</span>
<div class="mdt-side__item-main">
<div class="mdt-side__item-name">{{ tpl.nome_template }}</div>
<div class="mdt-side__item-tipo">{{ tipoLabel(tpl.tipo) }}</div>
<div v-if="tpl.descricao" class="mdt-side__item-desc">{{ tpl.descricao }}</div>
</div>
<i class="pi pi-eye mdt-side__item-action" v-tooltip.left="'Visualizar'" />
</li>
</ul>
</aside>
</Teleport>
<!-- COL 2 Main: Seus documentos OU Preview do sistema -->
<main class="mdt-main">
<!-- HEADER: muda conforme view -->
<header class="mdt-main__head">
<template v-if="view === 'list'">
<div class="mdt-main__title-row">
<div class="mdt-main__title">
<i class="pi pi-user-edit" />
<span>Seus documentos</span>
</div>
<span class="mdt-page__count">{{ tenantTemplates.length }}</span>
</div>
<p class="mdt-main__subtitle">
Templates personalizados da sua clínica. Crie do zero ou duplique um modelo do sistema.
</p>
</template>
<template v-else-if="view === 'preview' && previewTemplate">
<div class="mdt-main__title-row">
<div class="mdt-main__title">
<i class="pi pi-eye mdt-main__title-icon-eye" />
<span>{{ previewTemplate.nome_template }}</span>
</div>
<div class="mdt-preview-actions">
<button class="mdt-act-btn" @click="view = 'list'">
<i class="pi pi-arrow-left" />
<span>Voltar</span>
</button>
<button class="mdt-act-btn mdt-act-btn--primary" @click="onDuplicate(previewTemplate)">
<i class="pi pi-copy" />
<span>Duplicar pra meus templates</span>
</button>
</div>
</div>
<p class="mdt-main__subtitle">
<strong>{{ tipoLabel(previewTemplate.tipo) }}</strong>
<span v-if="previewTemplate.descricao"> · {{ previewTemplate.descricao }}</span>
</p>
</template>
</header>
<!-- Menu de ações -->
<div class="mdt-card__menu" @click.stop>
<Button
icon="pi pi-ellipsis-v"
text
rounded
size="small"
class="!w-7 !h-7"
@click.stop="$refs[`menu_${tpl.id}`]?.[0]?.toggle($event)"
/>
<Menu
:ref="`menu_${tpl.id}`"
:model="getCardMenuItems(tpl)"
:popup="true"
/>
</div>
<div class="mdt-card__foot">
<span v-if="!tpl.ativo" class="mdt-card__badge mdt-card__badge--inactive">
inativo
</span>
<span class="mdt-card__vars">
<i class="pi pi-code" />
{{ tpl.variaveis?.length || 0 }} variáveis
</span>
</div>
<!-- PREVIEW VIEW: iframe com o template renderizado -->
<div v-if="view === 'preview' && previewTemplate" class="mdt-preview-wrap">
<iframe
:srcdoc="previewHtml"
class="mdt-preview-iframe"
sandbox="allow-same-origin"
title="Pré-visualização do template"
/>
<div v-if="previewTemplate.variaveis?.length" class="mdt-preview-vars">
<i class="pi pi-info-circle" />
<span>
Este template usa {{ previewTemplate.variaveis.length }} variável(eis):
<code>{{ formatVarsPreview(previewTemplate.variaveis, 5) }}</code>
<span v-if="previewTemplate.variaveis.length > 5"> e +{{ previewTemplate.variaveis.length - 5 }}</span>
</span>
</div>
</div>
</div>
</template>
<!-- LIST VIEW: empty ou grid -->
<div v-else-if="!tenantTemplates.length" class="mdt-main__empty">
<i class="pi pi-file-edit mdt-main__empty-icon" />
<div class="mdt-main__empty-title">Nenhum template personalizado ainda</div>
<div class="mdt-main__empty-hint">
Clique em "Novo template" no topo da página ou duplique um modelo do sistema na coluna ao lado.
</div>
<button class="mdt-act-btn mdt-act-btn--primary mdt-main__empty-btn" @click="openCreate">
<i class="pi pi-plus" />
<span>Criar primeiro template</span>
</button>
</div>
<!-- DataView dos templates do tenant paginação + layout grid -->
<DataView
v-else
:value="tenantTemplates"
layout="grid"
:paginator="tenantTemplates.length > 12"
:rows="12"
class="mdt-dataview"
>
<template #grid="slotProps">
<div class="mdt-grid">
<div
v-for="tpl in slotProps.items"
:key="tpl.id"
class="mdt-card mdt-card--tenant"
role="button"
tabindex="0"
@click="openEdit(tpl)"
@keydown.enter.prevent="openEdit(tpl)"
>
<div class="mdt-card__head">
<span class="mdt-card__icon mdt-card__icon--primary">
<i class="pi pi-file-edit" />
</span>
<div class="mdt-card__main">
<div class="mdt-card__name">{{ tpl.nome_template }}</div>
<div class="mdt-card__tipo">{{ tipoLabel(tpl.tipo) }}</div>
<div v-if="tpl.descricao" class="mdt-card__desc">{{ tpl.descricao }}</div>
</div>
</div>
<!-- Menu de ações -->
<div class="mdt-card__menu" @click.stop>
<Button
icon="pi pi-ellipsis-v"
text
rounded
size="small"
class="!w-7 !h-7 mdt-card__menu-btn"
@click.stop="$refs[`menu_${tpl.id}`]?.[0]?.toggle($event)"
/>
<Menu
:ref="`menu_${tpl.id}`"
:model="getCardMenuItems(tpl)"
:popup="true"
/>
</div>
<div class="mdt-card__foot">
<span v-if="!tpl.ativo" class="mdt-card__badge mdt-card__badge--inactive">
inativo
</span>
<span class="mdt-card__vars">
&lt; {{ tpl.variaveis?.length || 0 }} variáveis &gt;
</span>
</div>
</div>
</div>
</template>
</DataView>
</main>
</div>
</template>
<!-- CREATE / EDIT VIEW -->
@@ -445,15 +650,19 @@ onMounted(() => {
color: var(--m-text);
}
.mdt-act-btn:hover { background: var(--m-bg-soft-hover); }
/* Estilo outlined: borda primary + texto primary + bg transparente.
Resolve problema do modo escuro onde bg primary deixava o texto
ilegível (cor primary clara contra texto branco). */
.mdt-act-btn--primary {
background: var(--m-accent);
border-color: var(--m-accent);
color: white;
background: transparent;
border-color: var(--p-primary-color);
color: var(--p-primary-color);
}
.mdt-act-btn--primary:hover {
background: color-mix(in srgb, var(--m-accent) 88%, white);
background: color-mix(in srgb, var(--p-primary-color) 10%, transparent);
transform: translateY(-1px);
}
.mdt-act-btn--primary > i { color: var(--p-primary-color); }
.mdt-act-btn > i { font-size: 0.78rem; }
/* Subheader */
@@ -473,19 +682,307 @@ onMounted(() => {
.mdt-subheader__text { flex: 1; min-width: 0; }
.mdt-subheader__text strong { color: var(--m-text); font-weight: 600; }
/* Body */
/* Body (container externo — fica em flex pra acomodar 2-col body OU editor full) */
.mdt-body {
flex: 1;
overflow-y: auto;
padding: 12px;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ─── Layout 2-col: sidebar (sistema) + main (do tenant) ─── */
.mdt-cols {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(280px, 360px) 1fr;
gap: 12px;
padding: 12px;
overflow: hidden;
}
@media (max-width: 900px) {
.mdt-cols {
grid-template-columns: 1fr;
overflow-y: auto;
}
}
/* ── COL 1: Sidebar (templates do sistema) ── */
.mdt-side {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 12px;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.mdt-side__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 14px 8px;
flex-shrink: 0;
}
.mdt-side__title {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.86rem;
font-weight: 700;
color: var(--m-text);
}
.mdt-side__title > i {
font-size: 0.9rem;
color: var(--m-text-muted);
}
.mdt-side__subtitle {
margin: 0 14px 8px;
font-size: 0.74rem;
color: var(--m-text-muted);
line-height: 1.45;
flex-shrink: 0;
}
.mdt-side__empty {
padding: 24px 14px;
text-align: center;
color: var(--m-text-muted);
font-size: 0.78rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.mdt-side__empty > i { font-size: 1.4rem; color: var(--m-text-faint); }
.mdt-side__list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 4px 8px 12px;
margin: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mdt-body::-webkit-scrollbar { width: 5px; }
.mdt-body::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
.mdt-side__list::-webkit-scrollbar { width: 5px; }
.mdt-side__list::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
.mdt-side__item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 10px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 9px;
color: var(--m-text);
cursor: pointer;
transition: background-color 140ms ease, border-color 140ms ease;
}
.mdt-side__item:hover,
.mdt-side__item:focus-visible {
background: var(--m-bg-soft-hover);
border-color: var(--p-primary-color);
outline: none;
}
.mdt-side__item.is-active {
background: color-mix(in srgb, var(--p-primary-color) 12%, var(--m-bg-medium));
border-color: var(--p-primary-color);
}
.mdt-side__item.is-active .mdt-side__item-action {
opacity: 1;
}
.mdt-side__item-icon {
width: 28px;
height: 28px;
display: grid;
place-items: center;
background: color-mix(in srgb, rgb(37, 99, 235) 14%, transparent);
color: rgb(37, 99, 235);
border-radius: 7px;
flex-shrink: 0;
font-size: 0.85rem;
}
.mdt-side__item-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.mdt-side__item-name {
font-size: 0.82rem;
font-weight: 600;
color: var(--m-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mdt-side__item-tipo {
font-size: 0.66rem;
color: var(--m-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.mdt-side__item-desc {
font-size: 0.72rem;
color: var(--m-text-muted);
margin-top: 3px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.mdt-side__item-action {
flex-shrink: 0;
color: var(--m-text-faint);
font-size: 0.78rem;
opacity: 0;
transition: opacity 140ms ease;
}
.mdt-side__item:hover .mdt-side__item-action {
opacity: 1;
}
/* ── COL 2: Main (Seus documentos) ── */
.mdt-main {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 12px;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.mdt-main__head {
padding: 12px 14px 8px;
border-bottom: 1px solid var(--m-border);
flex-shrink: 0;
}
.mdt-main__title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.mdt-main__title {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.86rem;
font-weight: 700;
color: var(--m-text);
}
.mdt-main__title > i {
font-size: 0.9rem;
color: var(--p-primary-color);
}
.mdt-main__subtitle {
margin: 6px 0 0;
font-size: 0.74rem;
color: var(--m-text-muted);
line-height: 1.45;
}
.mdt-main__empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 56px 28px;
text-align: center;
color: var(--m-text-muted);
gap: 8px;
}
.mdt-main__empty-icon { font-size: 2.4rem; color: var(--m-text-faint); margin-bottom: 6px; }
.mdt-main__empty-title { font-size: 0.95rem; font-weight: 600; color: var(--m-text); }
.mdt-main__empty-hint { font-size: 0.82rem; max-width: 360px; line-height: 1.5; }
.mdt-main__empty-btn { margin-top: 10px; }
.mdt-main .mdt-grid {
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
padding: 12px;
gap: 10px;
}
/* DataView wrapper — preenche o main e quebra em paginator/grid */
.mdt-dataview {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: transparent;
}
.mdt-dataview :deep(.p-dataview-content) {
flex: 1;
min-height: 0;
overflow-y: auto;
background: transparent;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mdt-dataview :deep(.p-dataview-content)::-webkit-scrollbar { width: 5px; }
.mdt-dataview :deep(.p-dataview-content)::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mdt-dataview :deep(.p-dataview-paginator-bottom) {
flex-shrink: 0;
background: transparent;
border-top: 1px solid var(--m-border);
}
/* ── Preview de template do sistema ── */
.mdt-preview-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.mdt-preview-wrap {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
background: #f4f4f4;
}
.mdt-preview-iframe {
flex: 1;
min-height: 0;
width: 100%;
border: none;
background: #ffffff;
}
.mdt-preview-vars {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: var(--m-bg-soft);
border-top: 1px solid var(--m-border);
color: var(--m-text-muted);
font-size: 0.74rem;
flex-shrink: 0;
}
.mdt-preview-vars > i { color: var(--p-primary-color); flex-shrink: 0; }
.mdt-preview-vars code {
font-family: 'JetBrains Mono', 'Consolas', monospace;
background: var(--m-bg-medium);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.7rem;
color: var(--p-primary-color);
}
/* Loading + Empty */
.mdt-loading,
@@ -534,25 +1031,8 @@ onMounted(() => {
font-size: 0.92rem;
color: var(--m-text-muted);
}
.mdt-section__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 22px;
padding: 0 8px;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
}
.mdt-section__count.is-info {
background: color-mix(in srgb, rgb(37, 99, 235) 18%, transparent);
color: rgb(37, 99, 235);
}
.mdt-section__count.is-accent {
background: var(--m-accent);
color: white;
}
/* .mdt-section__count removido substituido por .mdt-page__count
(mesmo estilo do header, evita conflito de contraste no dark mode) */
.mdt-grid {
display: grid;
@@ -575,6 +1055,9 @@ onMounted(() => {
text-align: left;
cursor: pointer;
font-family: inherit;
/* Acomoda titulo em ate 3 linhas + tipo + descricao 2 linhas + foot */
max-height: 240px;
overflow: hidden;
transition: background-color 140ms ease, border-color 140ms ease, transform 140ms ease;
}
.mdt-card:hover {
@@ -618,11 +1101,14 @@ onMounted(() => {
font-size: 0.92rem;
font-weight: 600;
color: var(--m-text);
line-height: 1.3;
/* Permite quebrar em até 3 linhas se o nome for longo */
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
word-break: break-word;
}
.mdt-card__tipo {
font-size: 0.72rem;
@@ -684,23 +1170,126 @@ onMounted(() => {
.mdt-card__foot {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 4px;
margin-top: auto;
padding-top: 8px;
}
.mdt-card__vars {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.7rem;
color: var(--m-text-muted);
font-size: 0.72rem;
font-weight: 600;
color: var(--p-primary-color);
font-family: 'JetBrains Mono', 'Consolas', monospace;
letter-spacing: 0.02em;
}
.mdt-card__vars > i { font-size: 0.65rem; }
/* Mobile (<1024px) */
/* Botão 3-pontos do menu do card — cor primary */
.mdt-card__menu-btn:deep(.p-button-icon) {
color: var(--p-primary-color) !important;
}
.mdt-card__menu-btn:hover:deep(.p-button-icon) {
color: var(--p-primary-color) !important;
opacity: 0.85;
}
/* Ícone eye do header de preview — cor primary */
.mdt-main__title-icon-eye {
color: var(--p-primary-color) !important;
}
/* ═══════ Botão "Menu" mobile-only (abre drawer com templates do sistema) ═══════ */
.mdt-menu-btn {
display: none;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: 8px;
border: 1px solid var(--m-border);
background: var(--m-bg-soft);
color: var(--m-text);
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
flex-shrink: 0;
}
.mdt-menu-btn:hover { background: var(--m-bg-soft-hover); }
/* ═══════ Mobile drawer (templates do sistema teleportados) ═══════ */
.mdt-mobile-drawer {
position: fixed;
top: 0; left: 0;
height: 100vh;
height: 100dvh;
width: min(360px, 88vw);
z-index: 80;
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border-right: 1px solid var(--m-border);
transform: translateX(-100%);
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
}
.mdt-mobile-drawer.is-open { transform: translateX(0); }
.mdt-mobile-drawer__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mdt-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.mdt-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* No mobile a .mdt-side é teleportada pra dentro do drawer scroll */
.mdt-mobile-drawer__scroll .mdt-side {
width: 100%;
height: 100%;
border: 1px solid var(--m-border);
}
.mdt-mobile-drawer__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 79;
}
.mdt-drawer-fade-enter-active,
.mdt-drawer-fade-leave-active { transition: opacity 200ms ease; }
.mdt-drawer-fade-enter-from,
.mdt-drawer-fade-leave-to { opacity: 0; }
/* ═══════ Mobile (<1024px) — ajustes ═══════ */
@media (max-width: 1023px) {
.mdt-page__title > span:nth-child(2):not(.mdt-page__count) {
font-size: 0.92rem;
/* Esconde a sidebar inline (templates do sistema viram drawer) */
.mdt-cols {
grid-template-columns: 1fr;
overflow: hidden;
}
.mdt-cols > .mdt-side { display: none; }
/* Mostra botão Menu, esconde título canônico (vira sub-info) */
.mdt-menu-btn--mobile-only { display: inline-flex; }
.mdt-page__title > span:not(.mdt-page__count) { display: none; }
.mdt-page__title-icon { display: none; }
.mdt-page__count { display: none; }
/* Compacta botões de ação */
.mdt-act-btn span { display: none; }
.mdt-act-btn { width: 32px; padding: 0; justify-content: center; }
.mdt-grid { grid-template-columns: 1fr; }
+8 -1
View File
@@ -72,7 +72,7 @@ const emit = defineEmits(['cronometro', 'toggle-filtro']);
class="resumo-link"
:class="{ 'is-active': filtroTipo === p.tipo }"
@click="emit('toggle-filtro', p.tipo)"
>{{ p.text }}</button><span v-if="i < resumoPartes.length - 2">,&nbsp;</span><span v-else-if="i === resumoPartes.length - 2">&nbsp;e&nbsp;</span>
>{{ p.text }}</button><span v-if="p.suffix" class="resumo-suffix">{{ p.suffix }}</span><span v-if="i < resumoPartes.length - 2">,&nbsp;</span><span v-else-if="i === resumoPartes.length - 2">&nbsp;e&nbsp;</span>
</template>.
</template>
</span>
@@ -135,6 +135,13 @@ const emit = defineEmits(['cronometro', 'toggle-filtro']);
border-bottom: 1px solid var(--m-text);
}
/* Sufixo "(1 foi cancelado, 2 foram remarcados)" texto secundario,
nao clicavel, com peso menor pra nao competir com o chip. */
.resumo-suffix {
color: var(--m-text-muted, rgba(255, 255, 255, 0.6));
font-size: 0.9em;
}
/* Modo "fundo nos textos soltos"
Toggle no painel Personalizar. Cada bloco de texto (.hero-text)
ganha um fundo solido translucido + borda + padding pra ficar
+401 -95
View File
@@ -575,9 +575,50 @@ function setPreset(name) {
//
// Settings popover (canto superior direito)
// Settings popover (canto inferior direito vive na .melissa-tray)
//
const settingsOpen = ref(false);
const cogBtnEl = ref(null);
// "More" tray popup visivel so em mobile (<md). Collapse de bell/
// help/cog/plan-DEV num menu vertical pra economizar largura.
const trayMoreOpen = ref(false);
const trayMoreBtnEl = ref(null);
// Fechar ao clicar fora: listener so existe enquanto o popover esta
// aberto. mousedown (capture) fecha antes do click chegar mas o
// proprio cog precisa ser ignorado, senao fecha aqui e o @click do
// botao re-abre na sequencia.
function onSettingsDocMouseDown(e) {
if (!settingsOpen.value) return;
const t = e.target;
if (!(t instanceof Element)) return;
if (t.closest('.mp-panel')) return; // clique dentro do panel
if (cogBtnEl.value?.contains(t)) return; // clique no proprio cog
settingsOpen.value = false;
}
watch(settingsOpen, (open) => {
if (open) document.addEventListener('mousedown', onSettingsDocMouseDown, true);
else document.removeEventListener('mousedown', onSettingsDocMouseDown, true);
});
onBeforeUnmount(() => document.removeEventListener('mousedown', onSettingsDocMouseDown, true));
// Mesmo padrao pro popup "More" do tray em mobile: ignora o proprio
// botao trigger (senao fecha + reabre no click) e fecha em qualquer
// outro lugar fora do panel.
function onTrayMoreDocMouseDown(e) {
if (!trayMoreOpen.value) return;
const t = e.target;
if (!(t instanceof Element)) return;
if (t.closest('.melissa-tray__more-panel')) return;
if (trayMoreBtnEl.value?.contains(t)) return;
trayMoreOpen.value = false;
}
watch(trayMoreOpen, (open) => {
if (open) document.addEventListener('mousedown', onTrayMoreDocMouseDown, true);
else document.removeEventListener('mousedown', onTrayMoreDocMouseDown, true);
});
onBeforeUnmount(() => document.removeEventListener('mousedown', onTrayMoreDocMouseDown, true));
//
// Timeline horizontal range/eco/posicoes/auto-scroll/cursor "Agora"
@@ -587,10 +628,19 @@ const settingsOpen = ref(false);
// Pai so passa eventos brutos + workRules/settings/feriados via props,
// e recebe @evento (pra abrir dialog) + @clear-filter (pra limpar tipo).
// Contagens por tipo + frase resumo do dia
// Contagens por tipo + frase resumo do dia. Pra sessao tambem quebro
// por status (cancelado/remarcado) pra montar o sufixo "(x foi cancelado,
// x foi remarcado)" depois do chip de atendimentos.
const contagensDia = computed(() => {
const c = { sessao: 0, supervisao: 0, reuniao: 0 };
for (const ev of eventosHojeReais.value) c[ev.tipo] = (c[ev.tipo] || 0) + 1;
const c = { sessao: 0, supervisao: 0, reuniao: 0, sessaoCancelada: 0, sessaoRemarcada: 0 };
for (const ev of eventosHojeReais.value) {
c[ev.tipo] = (c[ev.tipo] || 0) + 1;
if (ev.tipo === 'sessao') {
const s = String(ev.status || '').toLowerCase();
if (s === 'cancelado' || s === 'cancelada') c.sessaoCancelada += 1;
else if (s === 'remarcado') c.sessaoRemarcada += 1;
}
}
return c;
});
@@ -598,11 +648,34 @@ function pluralizar(n, singular, plural) {
return `${n} ${n === 1 ? singular : plural}`;
}
// Sufixo "(1 foi cancelado, 2 foram remarcados)" depois do chip de
// atendimentos quando houver sessoes cancel/remarcado no dia.
function _statusSuffix(qtdCancel, qtdRemarc) {
const partes = [];
if (qtdCancel > 0) {
partes.push(qtdCancel === 1
? `${qtdCancel} foi cancelado`
: `${qtdCancel} foram cancelados`);
}
if (qtdRemarc > 0) {
partes.push(qtdRemarc === 1
? `${qtdRemarc} foi remarcado`
: `${qtdRemarc} foram remarcados`);
}
return partes.length ? ` (${partes.join(', ')})` : '';
}
// Partes estruturadas pro template renderizar cada contagem como link clicável
const resumoPartes = computed(() => {
const c = contagensDia.value;
const partes = [];
if (c.sessao > 0) partes.push({ tipo: 'sessao', text: pluralizar(c.sessao, 'atendimento', 'atendimentos') });
if (c.sessao > 0) {
partes.push({
tipo: 'sessao',
text: pluralizar(c.sessao, 'atendimento', 'atendimentos'),
suffix: _statusSuffix(c.sessaoCancelada, c.sessaoRemarcada)
});
}
if (c.supervisao > 0) partes.push({ tipo: 'supervisao', text: pluralizar(c.supervisao, 'supervisão', 'supervisões') });
if (c.reuniao > 0) partes.push({ tipo: 'reuniao', text: pluralizar(c.reuniao, 'reunião', 'reuniões') });
return partes;
@@ -1745,6 +1818,22 @@ function _callOnAgenda(action) {
if (secaoAberta.value !== 'agenda') abrirSecao('agenda');
}
// MelissaBusca @goto-date usuario digitou "hoje"/"20/06" na busca
// global. Abre a agenda se fechada (via _callOnAgenda que enfileira a
// action ate o ref aparecer) e chama gotoDate exposto pela MelissaAgenda.
function onBuscaGotoDate(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return;
_callOnAgenda((agenda) => agenda.gotoDate?.(date));
}
// Ref + provide pra qualquer secao filha pedir pra abrir a busca global
// programaticamente. UI nao tem mais botao por secao (lupa unica fica
// na .melissa-tray), mas o inject permanece exposto pra acoes contextuais
// futuras (ex: "buscar paciente" num componente filho que quer abrir
// o spotlight com query pre-preenchida).
const melissaBuscaRef = ref(null);
provide('openMelissaBusca', () => melissaBuscaRef.value?.openDialog?.());
function onAbrirProntuario() {
const ev = eventoSelecionado.value;
if (!ev?.patient_id) {
@@ -1987,6 +2076,30 @@ const { toqueTermino, testarToque } = useMelissaToques('sino');
function abrirCronometro() {
cronoRef.value?.abrir();
}
// Click no botao de um evento da timeline (ou no CTA do card
// "Proximo paciente" quando o evento esta em curso). Pre-seleciona
// o paciente + autostart. Se ja houver cronometro rodando de outro
// paciente, mostra toast sem trocar (opcao b decidida 2026-05-22).
function onIniciarCronometroFromEvento(ev) {
if (!ev?.patient_id) return;
// Plano programado: horario original do evento na agenda. So passa se
// os campos forem numericos validos abrir() sanitiza de novo internamente.
const sessionPlan = (typeof ev.startH === 'number' && typeof ev.endH === 'number')
? { startH: ev.startH, endH: ev.endH }
: null;
const ret = cronoRef.value?.abrir({ pacienteId: ev.patient_id, autostart: true, sessionPlan });
if (ret && !ret.opened && ret.alreadyRunning && !ret.samePaciente) {
const atualNome = pacientesReais.value.find((p) => String(p.id) === String(ret.pacienteId))?.nome
|| 'outro paciente';
toast.add({
severity: 'warn',
summary: 'Cronômetro já ativo',
detail: `Sessão de ${atualNome} em andamento. Pare o cronômetro atual antes de iniciar outro.`,
life: 3500
});
}
}
function fecharCronometro() {
cronoRef.value?.fechar();
}
@@ -2347,71 +2460,6 @@ function onKeydown(e) {
<!-- PLANO DE TRÁS Resumo (recebe blur quando workspace abre) -->
<!-- -->
<div class="win11-summary" :class="{ 'is-behind': summaryDimmed }">
<!-- Faixa de fundo do topbar gradiente horizontal
(cor solida na direita -> transparente na esquerda)
pra dar legibilidade aos icones sem virar barra solida.
Cor flipa com light/dark via --m-band. -->
<div class="melissa-topbar-band" aria-hidden="true"></div>
<!-- Topbar Melissa (canto sup. direito): plan-DEV + notificações
+ ajuda + cog. Os 3 primeiros vêm do AppTopbar replicados
aqui porque a rota /melissa é fullscreen e não monta o
AppLayout. Drawer de notificações também é montado abaixo
(AjudaDrawer é global no App.vue). -->
<div class="absolute top-5 right-5 z-30 flex items-center gap-2">
<!-- Plan switcher DEV ( aparece em dev / com flag) -->
<button
v-if="showPlanDevMenu"
ref="planBtn"
class="glass-btn w-10 h-10 grid place-items-center"
:disabled="planMenuLoading || trocandoPlano"
title="Plano (DEV)"
@click="openPlanMenu"
>
<i v-if="planMenuLoading || trocandoPlano" class="pi pi-spin pi-spinner text-white/90 text-base" />
<i v-else class="pi pi-sliders-h text-white/90 text-base" />
</button>
<!-- Notificações -->
<button
class="glass-btn w-10 h-10 grid place-items-center relative"
title="Notificações"
@click="notificationStore.drawerOpen = true"
>
<i class="pi pi-bell text-white/90 text-base" />
<span
v-if="notificationStore.unreadCount > 0"
class="m-topbar-badge"
>{{ notificationStore.unreadCount > 99 ? '99+' : notificationStore.unreadCount }}</span>
</button>
<!-- Ajuda -->
<button
class="glass-btn w-10 h-10 grid place-items-center"
:class="{ 'glass-btn--active': ajudaDrawerOpen }"
:title="ajudaDrawerOpen ? 'Fechar ajuda' : 'Ajuda'"
@click="toggleAjuda"
>
<i class="pi pi-question-circle text-white/90 text-base" />
</button>
<!-- Cog (settings popover) existente -->
<button
class="glass-btn w-10 h-10 grid place-items-center"
title="Personalizar"
@click="settingsOpen = !settingsOpen"
>
<i class="pi pi-cog text-white/90 text-base" />
</button>
<Transition name="settings-pop">
<MelissaSettingsPanel
v-if="settingsOpen"
@close="settingsOpen = false"
/>
</Transition>
</div>
<!-- Conteúdo central -->
<div class="win11-summary__inner">
<!-- Bloco hero: relógio + data + saudação + resumo do dia -->
@@ -2428,6 +2476,7 @@ function onKeydown(e) {
<!-- Busca rápida (entre o "Hoje há" e a timeline) -->
<MelissaBusca
ref="melissaBuscaRef"
class="mt-8"
:pacientes="pacientesReais"
:eventos="eventosHojeReais"
@@ -2436,6 +2485,7 @@ function onKeydown(e) {
@evento="abrirEvento"
@documento="(d) => d?.patient_id ? router.push({ path: '/melissa/paciente', query: { id: String(d.patient_id), tab: 'documentos' } }) : abrirSecao('pacientes')"
@intake="() => abrirSecao('cadastros-recebidos')"
@goto-date="onBuscaGotoDate"
/>
<!-- Timeline horizontal + vertical (responsivo) -->
@@ -2448,6 +2498,7 @@ function onKeydown(e) {
:filtro-tipo="filtroTipo"
@evento="abrirEvento"
@clear-filter="limparFiltro"
@iniciar-cronometro="onIniciarCronometroFromEvento"
/>
<!-- Cards (catálogo + ativos + layout switchável) -->
@@ -2460,14 +2511,24 @@ function onKeydown(e) {
:class="cardsLayout === 'linha-unica' ? 'cards-row--linha' : 'cards-row--wrap'"
>
<template v-for="cardId in cardsAtivos" :key="cardId">
<!-- Próximo paciente -->
<!-- Próximo paciente. Se o evento esta em curso E tem
paciente, action vira "Iniciar cronômetro" pra
facilitar o fluxo "estou comecando a sessao agora". -->
<MelissaCard
v-if="cardId === 'proximo-paciente'"
icon="pi pi-user"
icon-color="text-emerald-300"
title="Próximo paciente"
:action-title="proximoPaciente ? 'Abrir sessão' : 'Abrir Pacientes'"
@open="proximoPaciente ? abrirEvento(proximoPaciente.ev) : abrirSecao('pacientes')"
:action-title="proximoPaciente
? (proximoPaciente.emCurso && proximoPaciente.ev?.patient_id
? 'Iniciar cronômetro'
: 'Abrir sessão')
: 'Abrir Pacientes'"
@open="proximoPaciente
? (proximoPaciente.emCurso && proximoPaciente.ev?.patient_id
? onIniciarCronometroFromEvento(proximoPaciente.ev)
: abrirEvento(proximoPaciente.ev))
: abrirSecao('pacientes')"
>
<div v-if="proximoPaciente" class="flex items-center gap-3">
<div
@@ -2613,14 +2674,163 @@ function onKeydown(e) {
<span class="psi-kbd" aria-hidden="true">Ctrl \</span>
</button>
<!-- -->
<!-- TRAY Melissa (system tray win11-style, canto inf. direito) -->
<!-- busca + plan-DEV + notificações + ajuda + cog. Sibling de -->
<!-- .dock (fora de .win11-summary) pra ficar sempre interativo,-->
<!-- mesmo com secao aberta (que aplica blur+pointer-none na -->
<!-- summary). AjudaDrawer ja e global no App.vue. -->
<!-- -->
<div class="melissa-tray">
<!-- Busca global (Ctrl+K) afordancia visivel pra mouse/touch.
Centraliza o ponto de acesso entre seccoes (em vez de
cada toolbar ter o proprio botao). -->
<button
class="glass-btn w-10 h-10 grid place-items-center"
v-tooltip.top="'Buscar (Ctrl+K)'"
aria-label="Busca global"
@click="melissaBuscaRef?.openDialog?.()"
>
<i class="pi pi-search text-white/90 text-base" />
</button>
<!-- Plan switcher DEV ( aparece em dev / com flag).
Em mobile (<md) some vai pro popup do "more". -->
<button
v-if="showPlanDevMenu"
ref="planBtn"
class="glass-btn w-10 h-10 hidden md:grid place-items-center"
:disabled="planMenuLoading || trocandoPlano"
v-tooltip.top="'Plano (DEV)'"
@click="openPlanMenu"
>
<i v-if="planMenuLoading || trocandoPlano" class="pi pi-spin pi-spinner text-white/90 text-base" />
<i v-else class="pi pi-sliders-h text-white/90 text-base" />
</button>
<!-- Notificações em mobile (<md) some, vai pro popup. -->
<button
class="glass-btn w-10 h-10 hidden md:grid place-items-center relative"
v-tooltip.top="'Notificações'"
@click="notificationStore.drawerOpen = true"
>
<i class="pi pi-bell text-white/90 text-base" />
<span
v-if="notificationStore.unreadCount > 0"
class="m-topbar-badge"
>{{ notificationStore.unreadCount > 99 ? '99+' : notificationStore.unreadCount }}</span>
</button>
<!-- Ajuda (conteudo muda com a rota via AjudaDrawer global).
data-ajuda-toggle: marca pro AjudaDrawer ignorar este botao
na deteccao de "clicou fora pra fechar" (senao fecha aqui
e o @click reabre). Em mobile (<md) some, vai pro popup. -->
<button
class="glass-btn w-10 h-10 hidden md:grid place-items-center"
:class="{ 'glass-btn--active': ajudaDrawerOpen }"
v-tooltip.top="ajudaDrawerOpen ? 'Fechar ajuda' : 'Ajuda'"
data-ajuda-toggle
@click="toggleAjuda"
>
<i class="pi pi-question-circle text-white/90 text-base" />
</button>
<!-- Cog (settings popover abre pra CIMA vide MelissaSettingsPanel).
Em mobile (<md) some, vai pro popup. -->
<button
ref="cogBtnEl"
class="glass-btn w-10 h-10 hidden md:grid place-items-center"
v-tooltip.top="'Personalizar'"
@click="settingsOpen = !settingsOpen"
>
<i class="pi pi-cog text-white/90 text-base" />
</button>
<Transition name="settings-pop">
<MelissaSettingsPanel
v-if="settingsOpen"
@close="settingsOpen = false"
/>
</Transition>
<!-- "More" so em mobile (<md). Collapse de plan-DEV+bell+help+
cog num popup vertical. Dot vermelho aparece se houver
notificacoes nao-lidas (preserva o sinal visual da bell). -->
<button
ref="trayMoreBtnEl"
class="glass-btn w-10 h-10 grid md:hidden place-items-center relative"
:class="{ 'glass-btn--active': trayMoreOpen }"
v-tooltip.top="trayMoreOpen ? 'Fechar' : 'Mais'"
aria-label="Mais opcoes"
@click="trayMoreOpen = !trayMoreOpen"
>
<i class="pi pi-ellipsis-v text-white/90 text-base" />
<span
v-if="notificationStore.unreadCount > 0"
class="melissa-tray__more-dot"
aria-hidden="true"
/>
</button>
<Transition name="settings-pop">
<div v-if="trayMoreOpen" class="melissa-tray__more-panel glass-panel">
<!-- Notificacoes -->
<button
class="melissa-tray__more-item"
@click="notificationStore.drawerOpen = true; trayMoreOpen = false"
>
<i class="pi pi-bell" />
<span class="flex-1 text-left">Notificações</span>
<span
v-if="notificationStore.unreadCount > 0"
class="m-topbar-badge"
style="position: static; transform: none;"
>{{ notificationStore.unreadCount > 99 ? '99+' : notificationStore.unreadCount }}</span>
</button>
<!-- Ajuda -->
<button
class="melissa-tray__more-item"
:class="{ 'is-active': ajudaDrawerOpen }"
data-ajuda-toggle
@click="toggleAjuda(); trayMoreOpen = false"
>
<i class="pi pi-question-circle" />
<span class="flex-1 text-left">{{ ajudaDrawerOpen ? 'Fechar ajuda' : 'Ajuda' }}</span>
</button>
<!-- Personalizar (cog) -->
<button
class="melissa-tray__more-item"
@click="settingsOpen = !settingsOpen; trayMoreOpen = false"
>
<i class="pi pi-cog" />
<span class="flex-1 text-left">Personalizar</span>
</button>
<!-- Plano DEV (so se flag) -->
<button
v-if="showPlanDevMenu"
class="melissa-tray__more-item"
:disabled="planMenuLoading || trocandoPlano"
@click="openPlanMenu($event); trayMoreOpen = false"
>
<i :class="planMenuLoading || trocandoPlano ? 'pi pi-spin pi-spinner' : 'pi pi-sliders-h'" />
<span class="flex-1 text-left">Plano (DEV)</span>
</button>
</div>
</Transition>
</div>
<!-- -->
<!-- DOCK (taskbar Win11-style sem chrome) receptáculo pra -->
<!-- elementos minimizados (cronômetro, futuros) + atalhos -->
<!-- pinned (Agenda, WhatsApp). Transparent, os items são -->
<!-- clicáveis. ψ vive ao lado (absolute, bottom-left). -->
<!-- pinned (Agenda, Pacientes, WhatsApp, Financeiro). Transp.-->
<!-- os items sao clicaveis. ψ vive ao lado (absolute, -->
<!-- bottom-left). -->
<!-- -->
<div class="melissa-dock">
<!-- Pinned: atalhos diretos pras seções mais usadas.
<!-- Pinned builtins: 4 atalhos pras secoes principais.
Tamanho menor que ψ (44px vs 56px) + cantos arredondados
mas não full-circle, pra hierarquia visual ficar óbvia. -->
<button
@@ -2632,6 +2842,15 @@ function onKeydown(e) {
>
<i class="pi pi-calendar" />
</button>
<button
type="button"
class="dock-pin"
v-tooltip.top="'Pacientes'"
:class="{ 'dock-pin--active': secaoAberta === 'pacientes' }"
@click="abrirSecao('pacientes')"
>
<i class="pi pi-users" />
</button>
<button
type="button"
class="dock-pin"
@@ -2646,12 +2865,24 @@ function onKeydown(e) {
:title="`${whatsappPendente.count} mensagens não lidas`"
>{{ whatsappPendente.count > 99 ? '99+' : whatsappPendente.count }}</span>
</button>
<button
type="button"
class="dock-pin"
v-tooltip.top="'Financeiro'"
:class="{ 'dock-pin--active': secaoAberta === 'financeiro' }"
@click="abrirSecao('financeiro')"
>
<i class="pi pi-wallet" />
</button>
<!-- Divisor entre builtins e pins dinâmicos. aparece se
o user tem pelo menos 1 pin (fixo ou recente). -->
o user tem pelo menos 1 pin (fixo ou recente).
Em mobile (<md), se so houver MRU (que e oculto), o
divisor tambem some pra nao "soltar" no fim do dock. -->
<div
v-if="dockPins.pinned.value.length || dockPins.recent.value.length"
class="dock-divider"
:class="{ 'hidden md:block': !dockPins.pinned.value.length }"
aria-hidden="true"
/>
@@ -2671,7 +2902,11 @@ function onKeydown(e) {
</button>
<!-- Pins MRU (max 3) empurrados pelas últimas seções abertas.
Visual mais leve (opacity menor) pra destacar dos fixos. -->
Visual mais leve (opacity menor) pra destacar dos fixos.
Em mobile (<md=768px) sao ocultos via media query no CSS
do .dock-pin--recent utility 'hidden' do Tailwind perde
pro 'display: grid' base do .dock-pin (mesma specificity,
ordem de carga). -->
<button
v-for="slug in dockPins.recent.value" :key="`r-${slug}`"
type="button"
@@ -3856,7 +4091,7 @@ function onKeydown(e) {
.settings-pop-enter-from,
.settings-pop-leave-to {
opacity: 0;
transform: translateY(-6px) scale(0.98);
transform: translateY(6px) scale(0.98);
}
/* line-clamp util (caso Tailwind não tenha) */
@@ -4011,24 +4246,88 @@ function onKeydown(e) {
--m-hero-text-border: rgba(255, 255, 255, 0.12);
}
/* Faixa de fundo do topbar (canto sup. direito)
Gradiente horizontal: cor solida na direita (onde os icones vivem)
e fade pra transparente na esquerda. z-index abaixo do topbar
(z-30) e acima do conteudo principal. */
.melissa-topbar-band {
/* Tray Melissa (system tray win11-style, canto inferior direito)
Posiciona o grupo de icones globais (plan-DEV, notificacoes, ajuda,
cog) alinhado verticalmente com os pins do dock. z-index acima do
dock (65) pra ficar no mesmo plano interativo. */
.melissa-tray {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 80px;
z-index: 25;
bottom: 0;
right: 1.25rem;
height: var(--m-dock-h, 76px);
z-index: 66;
display: flex;
align-items: center;
gap: 8px;
}
/* Dot vermelho no botao "More" da tray (mobile) sinaliza que ha
notificacoes nao-lidas escondidas dentro do popup. */
.melissa-tray__more-dot {
position: absolute;
top: 6px;
right: 6px;
width: 8px;
height: 8px;
border-radius: 9999px;
background: rgb(239, 68, 68);
border: 1.5px solid var(--m-bg-medium, rgba(0, 0, 0, 0.4));
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
pointer-events: none;
background: linear-gradient(
to left,
var(--m-band) 0%,
var(--m-band) 25%,
transparent 75%
);
}
/* Popup vertical do "More" abre acima do botao trigger, mesmo padrao
visual do MelissaSettingsPanel mas mais compacto. */
.melissa-tray__more-panel {
position: absolute;
bottom: calc(var(--m-dock-h, 76px) - 8px);
right: 0;
min-width: 220px;
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
border-radius: 14px;
z-index: 67;
}
.melissa-tray__more-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
background: transparent;
border: none;
color: var(--m-text);
border-radius: 8px;
cursor: pointer;
font-family: inherit;
font-size: 0.88rem;
text-align: left;
transition: background-color 140ms ease, color 140ms ease;
}
.melissa-tray__more-item:hover:not(:disabled),
.melissa-tray__more-item:focus-visible {
background: var(--m-bg-soft-hover);
outline: none;
}
.melissa-tray__more-item.is-active {
background: var(--m-bg-soft-hover);
color: white;
}
.melissa-tray__more-item:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.melissa-tray__more-item > i {
font-size: 0.95rem;
width: 16px;
color: var(--m-text-muted);
flex-shrink: 0;
}
.melissa-tray__more-item:hover > i,
.melissa-tray__more-item.is-active > i {
color: var(--m-text);
}
/* Dock (global pra atravessar Teleport + evitar perda de scoped
@@ -4165,6 +4464,13 @@ html:not(.app-dark) .dock-divider {
.dock-pin--recent.dock-pin--active {
opacity: 1;
}
/* Em mobile (<md=768px) os pins MRU somem pra economizar largura
(4 builtins + user-fixed pins ja saturam o dock). Media query no
bloco do .dock-pin--recent ganha do utility 'hidden' do Tailwind
por ordem de carga (mesma specificity). */
@media (max-width: 767px) {
.dock-pin--recent { display: none; }
}
/* Skeleton loading utilitário (global pra atravessar scoped CSS dos
componentes filhos). Use a classe .melissa-skeleton em qualquer
+6 -11
View File
@@ -24,7 +24,7 @@ import { useToast } from 'primevue/usetoast';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import { MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue';
import DocumentsListPage from '@/features/documents/DocumentsListPage.vue';
import MelissaPatientDocuments from '@/layout/melissa/MelissaPatientDocuments.vue';
import PatientConversationsTab from '@/features/patients/prontuario/PatientConversationsTab.vue';
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
import { usePatientDetail } from '@/features/patients/composables/usePatientDetail';
@@ -2092,16 +2092,11 @@ onBeforeUnmount(() => {
</article>
</div>
<!-- DocumentsListPage embedded (ja vem com upload/preview/lista) -->
<section class="mpa-w mpa-embed">
<div class="mpa-w__body mpa-embed__body">
<DocumentsListPage
:patient-id="patientId"
:patient-name="nomeCompleto || ''"
embedded
/>
</div>
</section>
<!-- Documentos nativos Melissa (2-col com tipos na sidebar) -->
<MelissaPatientDocuments
:patient-id="patientId"
:patient-name="nomeCompleto || ''"
/>
</template>
</div>
+3
View File
@@ -313,6 +313,9 @@ async function refetchTudo() {
}
// Estado de UI
// Busca local filtra a lista visivel combinada com filtros de
// status/grupo/tag. Busca global (Ctrl+K) tem botao dedicado no
// .melissa-tray, fora desta seccao.
const busca = ref('');
const statusFiltro = ref('ativos'); // 'todos' | 'ativos' | 'inativos' | 'arquivados'
const grupoFiltroId = ref(null); // null = todos
@@ -0,0 +1,763 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI MelissaPatientDocuments
|--------------------------------------------------------------------------
| Página nativa Melissa pra aba "Documentos" do prontuário (substitui o
| embed do DocumentsListPage).
|
| Layout 2-col (espelha MelissaDocumentosTemplates):
| - COL 1 (esquerda): tipos de documento como sidebar com contadores
| - COL 2 (direita): DataView dos documentos do tipo selecionado,
| com upload/preview/edit/sign/share/delete via dialogs reaproveitados
| - Mobile (<1024px): sidebar vira drawer (botão "Tipos" no header)
|
| Reaproveita do feature/documents:
| - useDocuments composable (fetch + CRUD + URLs)
| - DocumentCard pra item visual
| - DocumentUploadDialog / PreviewDialog / GenerateDialog /
| SignatureDialog / ShareDialog
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import Menu from 'primevue/menu';
import DataView from 'primevue/dataview';
import { useDocuments } from '@/features/documents/composables/useDocuments';
import DocumentCard from '@/features/documents/components/DocumentCard.vue';
import DocumentUploadDialog from '@/features/documents/components/DocumentUploadDialog.vue';
import DocumentPreviewDialog from '@/features/documents/components/DocumentPreviewDialog.vue';
import DocumentGenerateDialog from '@/features/documents/components/DocumentGenerateDialog.vue';
import DocumentSignatureDialog from '@/features/documents/components/DocumentSignatureDialog.vue';
import DocumentShareDialog from '@/features/documents/components/DocumentShareDialog.vue';
const props = defineProps({
patientId: { type: String, default: null },
patientName: { type: String, default: '' }
});
const toast = useToast();
const confirm = useConfirm();
// useDocuments composable
const {
documents, loading, error, filters, usedTags, stats,
TIPOS_DOCUMENTO,
fetchDocuments, upload, update, remove,
download, getPreviewUrl, fetchUsedTags
} = useDocuments(() => props.patientId);
// Dialogs
const uploadDlg = ref(false);
const previewDlg = ref(false);
const generateDlg = ref(false);
const signatureDlg = ref(false);
const shareDlg = ref(false);
const selectedDoc = ref(null);
const previewUrl = ref('');
// Ref dedicado pro modo edicao do generate dialog. Separado do selectedDoc
// (que tambem alimenta preview/share/sign/delete) pra evitar vazar "edit
// state" pro "Gerar" do header quando o user so abre preview e fecha.
const editingDoc = ref(null);
// Tipo selecionado (filtro pela sidebar)
// null = todos os tipos
const selectedTipo = ref(null);
const docsByTipo = computed(() => {
const groups = {};
for (const d of documents.value) {
const tipo = d.tipo_documento || 'outro';
if (!groups[tipo]) groups[tipo] = [];
groups[tipo].push(d);
}
return groups;
});
const tipoCounts = computed(() => {
const counts = {};
TIPOS_DOCUMENTO.forEach(t => { counts[t.value] = (docsByTipo.value[t.value] || []).length; });
return counts;
});
const filteredDocs = computed(() => {
if (!selectedTipo.value) return documents.value;
return docsByTipo.value[selectedTipo.value] || [];
});
function tipoLabel(tipo) {
return TIPOS_DOCUMENTO.find(t => t.value === tipo)?.label || tipo;
}
// Mobile drawer
const drawerOpen = ref(false);
const isMobile = ref(false);
let _mqMobile = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false;
}
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
function selectTipo(tipo) {
selectedTipo.value = tipo;
if (isMobile.value) drawerOpen.value = false;
}
// Actions
async function onUploaded({ file, meta }) {
try {
await upload(file, props.patientId, meta);
toast.add({ severity: 'success', summary: 'Enviado', detail: file.name, life: 3000 });
fetchUsedTags();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro no upload', detail: e?.message });
}
}
async function onPreview(doc) {
selectedDoc.value = doc;
try { previewUrl.value = await getPreviewUrl(doc); }
catch { previewUrl.value = ''; }
previewDlg.value = true;
}
function onDownload(doc) { download(doc); }
function onEdit(doc) {
// Abre o DocumentGenerateDialog em modo edicao (editingDocId passado).
// Dialog busca template + dados_preenchidos do document_generated e
// pre-popula tudo, pulando direto pra step 'edit'. Save substitui o PDF
// in-place no Storage e atualiza documents (preserva id + audit trail).
// Docs uploaded direto (sem registro generated) caem no flow normal de
// "select template" com um toast info.
editingDoc.value = doc;
previewDlg.value = false; // fecha preview se estiver aberto
generateDlg.value = true;
}
function onDelete(doc) {
confirm.require({
header: 'Excluir documento',
message: `Excluir "${doc.nome_original}"? Esta ação pode ser revertida no Lixo.`,
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Excluir',
rejectLabel: 'Cancelar',
acceptSeverity: 'danger',
accept: async () => {
try {
await remove(doc.id);
toast.add({ severity: 'success', summary: 'Excluído', detail: doc.nome_original, life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao excluir', detail: e?.message });
}
}
});
}
function onShare(doc) {
selectedDoc.value = doc;
shareDlg.value = true;
}
function onSign(doc) {
selectedDoc.value = doc;
signatureDlg.value = true;
}
function onGenerated() {
fetchDocuments();
fetchUsedTags();
}
onMounted(async () => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
isMobile.value = _mqMobile.matches;
try { _mqMobile.addEventListener('change', _onMqMobileChange); }
catch { _mqMobile.addListener(_onMqMobileChange); }
}
await Promise.all([fetchDocuments(), fetchUsedTags()]);
});
onBeforeUnmount(() => {
if (_mqMobile) {
try { _mqMobile.removeEventListener('change', _onMqMobileChange); }
catch { _mqMobile.removeListener(_onMqMobileChange); }
}
});
</script>
<template>
<!-- Mobile drawer (tipos) teleportado pro body pra escapar de
stacking contexts pais (transform/filter no MelissaPaciente
travavam o fixed). win11-root no wrapper pra herdar os
tokens --m-* definidos nesse escopo. -->
<Teleport to="body">
<div class="win11-root mpd-drawer-portal">
<Transition name="mpd-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="mpd-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
>
<div id="mpd-mobile-drawer-target" class="mpd-mobile-drawer__scroll" />
</div>
</Transition>
<Transition name="mpd-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="mpd-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
</div>
</Teleport>
<section class="mpd-page">
<!-- HEADER -->
<header class="mpd-page__head">
<button
class="mpd-menu-btn mpd-menu-btn--mobile-only"
v-tooltip.bottom="'Tipos de documento'"
@click="toggleDrawer"
>
<i class="pi pi-bars" />
<span>Tipos</span>
</button>
<div class="mpd-page__title">
<i class="pi pi-file mpd-page__title-icon" />
<span>Documentos</span>
<span class="mpd-page__count">{{ documents.length }}</span>
</div>
<div class="mpd-page__actions">
<button
class="mpd-act-btn"
v-tooltip.bottom="'Atualizar'"
:disabled="loading"
@click="fetchDocuments"
>
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'" />
</button>
<button
class="mpd-act-btn"
v-tooltip.bottom="'Gerar a partir de template'"
:disabled="!patientId"
@click="generateDlg = true"
>
<i class="pi pi-file-pdf" />
<span>Gerar</span>
</button>
<button
class="mpd-act-btn mpd-act-btn--primary"
v-tooltip.bottom="'Enviar arquivo'"
:disabled="!patientId"
@click="uploadDlg = true"
>
<i class="pi pi-upload" />
<span>Upload</span>
</button>
</div>
</header>
<!-- BODY 2-col -->
<div class="mpd-body">
<!-- COL 1 Sidebar de tipos (teleporta no mobile) -->
<Teleport to="#mpd-mobile-drawer-target" :disabled="!isMobile">
<aside class="mpd-side">
<div class="mpd-side__head">
<i class="pi pi-folder" />
<span>Tipos</span>
</div>
<ul class="mpd-side__list">
<li
class="mpd-side__item"
:class="{ 'is-active': selectedTipo === null }"
role="button"
tabindex="0"
@click="selectTipo(null)"
@keydown.enter.prevent="selectTipo(null)"
>
<span class="mpd-side__item-name">Todos</span>
<span class="mpd-side__item-count">{{ documents.length }}</span>
</li>
<li
v-for="t in TIPOS_DOCUMENTO"
:key="t.value"
class="mpd-side__item"
:class="{ 'is-active': selectedTipo === t.value, 'is-empty': tipoCounts[t.value] === 0 }"
role="button"
tabindex="0"
@click="selectTipo(t.value)"
@keydown.enter.prevent="selectTipo(t.value)"
>
<span class="mpd-side__item-name">{{ t.label }}</span>
<span class="mpd-side__item-count">{{ tipoCounts[t.value] || 0 }}</span>
</li>
</ul>
</aside>
</Teleport>
<!-- COL 2 Main: lista de documentos -->
<main class="mpd-main">
<header class="mpd-main__head">
<div class="mpd-main__title-row">
<div class="mpd-main__title">
<i class="pi pi-file" />
<span>{{ selectedTipo ? tipoLabel(selectedTipo) : 'Todos os documentos' }}</span>
</div>
<span class="mpd-page__count">{{ filteredDocs.length }}</span>
</div>
<p class="mpd-main__subtitle">
<template v-if="selectedTipo">
Documentos do tipo <strong>{{ tipoLabel(selectedTipo) }}</strong> deste paciente.
</template>
<template v-else>
Todos os documentos clínicos vinculados a este paciente.
</template>
</p>
</header>
<!-- Loading -->
<div v-if="loading && !documents.length" class="mpd-loading">
<i class="pi pi-spin pi-spinner" />
<span>Carregando documentos</span>
</div>
<!-- Empty global -->
<div v-else-if="!documents.length" class="mpd-empty">
<i class="pi pi-folder-open mpd-empty__icon" />
<div class="mpd-empty__title">Nenhum documento ainda</div>
<div class="mpd-empty__hint">
Faça upload de um arquivo ou gere um documento a partir de template.
</div>
<div class="mpd-empty__actions">
<button class="mpd-act-btn" @click="generateDlg = true">
<i class="pi pi-file-pdf" /><span>Gerar template</span>
</button>
<button class="mpd-act-btn mpd-act-btn--primary" @click="uploadDlg = true">
<i class="pi pi-upload" /><span>Upload</span>
</button>
</div>
</div>
<!-- Empty (filtrado) -->
<div v-else-if="!filteredDocs.length" class="mpd-empty">
<i class="pi pi-filter mpd-empty__icon" />
<div class="mpd-empty__title">Nenhum {{ tipoLabel(selectedTipo).toLowerCase() }} ainda</div>
<div class="mpd-empty__hint">
Outros tipos têm documentos
<button class="mpd-link" @click="selectTipo(null)">ver todos</button>.
</div>
</div>
<!-- DataView -->
<DataView
v-else
:value="filteredDocs"
layout="grid"
:paginator="filteredDocs.length > 12"
:rows="12"
class="mpd-dataview"
>
<template #grid="slotProps">
<div class="mpd-grid">
<DocumentCard
v-for="doc in slotProps.items"
:key="doc.id"
:doc="doc"
@preview="onPreview"
@download="onDownload"
@edit="onEdit"
@delete="onDelete"
@share="onShare"
@sign="onSign"
/>
</div>
</template>
</DataView>
</main>
</div>
<!-- Dialogs reaproveitados -->
<DocumentUploadDialog
v-model:visible="uploadDlg"
:patient-id="patientId"
@uploaded="onUploaded"
/>
<DocumentPreviewDialog
v-model:visible="previewDlg"
:doc="selectedDoc"
:preview-url="previewUrl"
@updated="fetchDocuments"
@download="onDownload"
@edit="onEdit"
@share="(d) => { previewDlg = false; onShare(d); }"
@sign="(d) => { previewDlg = false; onSign(d); }"
@delete="(d) => { previewDlg = false; onDelete(d); }"
/>
<!-- editing-doc-id vem do ref editingDoc dedicado so e setado
via onEdit (botao Editar). "Gerar" no header usa generateDlg=true
com editingDoc=null, abrindo limpo. Limpa editingDoc no
fechamento pra nao vazar pro proximo Gerar. -->
<DocumentGenerateDialog
v-if="patientId"
:visible="generateDlg"
:patient-id="patientId"
:patient-name="patientName"
:editing-doc-id="editingDoc?.id || null"
@generated="onGenerated"
@update:visible="(v) => { generateDlg = v; if (!v) editingDoc = null; }"
/>
<DocumentSignatureDialog
:visible="signatureDlg"
@update:visible="signatureDlg = $event"
:doc="selectedDoc"
/>
<DocumentShareDialog
v-model:visible="shareDlg"
:doc="selectedDoc"
/>
</section>
</template>
<style scoped>
/* ═══════ Page chrome ═══════ */
.mpd-page {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 14px;
overflow: hidden;
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
.mpd-page__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--m-border);
flex-shrink: 0;
gap: 10px;
}
.mpd-page__title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 1rem;
font-weight: 600;
}
.mpd-page__title-icon { color: var(--p-primary-color); font-size: 1rem; }
.mpd-page__count {
font-size: 0.7rem;
font-weight: 600;
color: var(--m-accent);
background: var(--m-accent-soft);
border: 1px solid color-mix(in srgb, var(--m-accent) 35%, transparent);
padding: 2px 8px;
border-radius: 999px;
}
.mpd-page__actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.mpd-act-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 12px;
border-radius: 9px;
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: background-color 140ms ease;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
color: var(--m-text);
}
.mpd-act-btn:hover:not(:disabled) { background: var(--m-bg-soft-hover); }
.mpd-act-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.mpd-act-btn--primary {
background: transparent;
border-color: var(--p-primary-color);
color: var(--p-primary-color);
}
.mpd-act-btn--primary:hover:not(:disabled) { background: color-mix(in srgb, var(--p-primary-color) 10%, transparent); }
.mpd-act-btn > i { font-size: 0.78rem; }
.mpd-menu-btn {
display: none;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: 8px;
border: 1px solid var(--m-border);
background: var(--m-bg-soft);
color: var(--m-text);
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
flex-shrink: 0;
}
.mpd-menu-btn:hover { background: var(--m-bg-soft-hover); }
/* ═══════ Body 2-col ═══════ */
.mpd-body {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(220px, 260px) 1fr;
gap: 12px;
padding: 12px;
overflow: hidden;
}
/* COL 1 — Sidebar */
.mpd-side {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 12px;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.mpd-side__head {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--m-border);
font-size: 0.86rem;
font-weight: 700;
flex-shrink: 0;
}
.mpd-side__head > i { color: var(--m-text-muted); font-size: 0.9rem; }
.mpd-side__list {
flex: 1;
min-height: 0;
overflow-y: auto;
margin: 0;
padding: 6px;
list-style: none;
scrollbar-width: thin;
}
.mpd-side__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
background: transparent;
border-radius: 7px;
cursor: pointer;
font-size: 0.82rem;
transition: background-color 120ms ease, color 120ms ease;
}
.mpd-side__item:hover,
.mpd-side__item:focus-visible {
background: var(--m-bg-soft-hover);
outline: none;
}
.mpd-side__item.is-active {
background: color-mix(in srgb, var(--p-primary-color) 12%, var(--m-bg-medium));
color: var(--p-primary-color);
font-weight: 600;
}
.mpd-side__item.is-empty { opacity: 0.5; }
.mpd-side__item-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mpd-side__item-count {
font-size: 0.7rem;
color: var(--m-text-muted);
background: var(--m-bg-medium);
padding: 2px 8px;
border-radius: 999px;
flex-shrink: 0;
}
.mpd-side__item.is-active .mpd-side__item-count {
color: var(--p-primary-color);
background: color-mix(in srgb, var(--p-primary-color) 18%, transparent);
}
/* COL 2 — Main */
.mpd-main {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 12px;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.mpd-main__head {
padding: 12px 14px 8px;
border-bottom: 1px solid var(--m-border);
flex-shrink: 0;
}
.mpd-main__title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.mpd-main__title {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.86rem;
font-weight: 700;
}
.mpd-main__title > i { color: var(--p-primary-color); font-size: 0.9rem; }
.mpd-main__subtitle {
margin: 6px 0 0;
font-size: 0.74rem;
color: var(--m-text-muted);
line-height: 1.45;
}
.mpd-loading,
.mpd-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 28px;
text-align: center;
color: var(--m-text-muted);
gap: 8px;
}
.mpd-loading > i { font-size: 1.4rem; color: var(--p-primary-color); }
.mpd-empty__icon { font-size: 2.4rem; color: var(--m-text-faint); margin-bottom: 6px; }
.mpd-empty__title { font-size: 0.95rem; font-weight: 600; color: var(--m-text); }
.mpd-empty__hint { font-size: 0.82rem; max-width: 360px; line-height: 1.5; }
.mpd-empty__actions { display: flex; gap: 8px; margin-top: 10px; }
.mpd-link {
background: none; border: none; color: var(--p-primary-color);
cursor: pointer; padding: 0; font: inherit; text-decoration: underline;
}
.mpd-dataview {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: transparent;
}
.mpd-dataview :deep(.p-dataview-content) {
flex: 1; min-height: 0; overflow-y: auto;
background: transparent;
scrollbar-width: thin;
}
.mpd-dataview :deep(.p-dataview-paginator-bottom) {
flex-shrink: 0; background: transparent;
border-top: 1px solid var(--m-border);
}
.mpd-grid {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
}
/* Drawer styles foram movidos pro <style> não-scoped abaixo
teleporte pro body perde o atributo data-v scoped */
/* ═══════ Mobile (<1024px) ═══════ */
@media (max-width: 1023px) {
.mpd-body {
grid-template-columns: 1fr;
overflow: hidden;
}
.mpd-body > .mpd-side { display: none; }
.mpd-menu-btn--mobile-only { display: inline-flex; }
.mpd-page__title > span:first-of-type { display: none; }
.mpd-page__title-icon { display: none; }
.mpd-act-btn span { display: none; }
.mpd-act-btn { width: 32px; padding: 0; justify-content: center; }
}
</style>
<!-- Styles do drawer mobile NÃO-scoped o <Teleport to="body"> tira o
elemento da árvore do componente, então perde os atributos data-v
do scoped. O wrapper .mpd-drawer-portal carrega .win11-root pra
herdar os tokens --m-* (definidos em MelissaLayout). -->
<style>
/* Wrapper inerte — não cria stacking, só serve como host das vars */
.mpd-drawer-portal {
/* Garante que mesmo sem .win11-root (improvável) caia em fallbacks
de PrimeVue tokens, que existem globalmente e respeitam dark/light. */
--mpd-bg: var(--m-bg-medium, var(--p-content-background, #ffffff));
--mpd-border: var(--m-border, var(--p-content-border-color, rgba(0,0,0,0.1)));
--mpd-text: var(--m-text, var(--p-text-color, #1a1a1a));
}
.mpd-mobile-drawer {
position: fixed;
top: 0;
left: 0;
height: 100vh;
height: 100dvh;
width: min(320px, 86vw);
z-index: 80;
background: var(--mpd-bg);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border-right: 1px solid var(--mpd-border);
transform: translateX(-100%);
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--mpd-text);
display: flex;
flex-direction: column;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
.mpd-mobile-drawer.is-open {
transform: translateX(0);
}
.mpd-mobile-drawer:not(.is-open) {
pointer-events: none;
}
.mpd-mobile-drawer__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
}
/* Sidebar teleportada herda altura plena */
.mpd-mobile-drawer__scroll .mpd-side {
width: 100%;
height: 100%;
border: 1px solid var(--mpd-border);
}
.mpd-mobile-drawer__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 79;
}
.mpd-drawer-fade-enter-active,
.mpd-drawer-fade-leave-active {
transition: opacity 200ms ease;
}
.mpd-drawer-fade-enter-from,
.mpd-drawer-fade-leave-to {
opacity: 0;
}
</style>
+316 -5
View File
@@ -31,7 +31,30 @@ const toast = useToast();
const confirm = useConfirm();
const router = useRouter();
const tenantStore = useTenantStore();
const { layoutConfig, setVariant } = useLayout();
const { layoutConfig, setVariant, isDarkTheme, toggleDarkMode } = useLayout();
// Opções do CHECK constraint da migration 20260521000003 (CFP #5)
const REGISTRATION_TYPE_OPTIONS = [
{ value: '', label: '— Não informado —' },
{ value: 'CRP', label: 'CRP — Psicólogo(a)' },
{ value: 'CRM', label: 'CRM — Médico(a)' },
{ value: 'CRFa', label: 'CRFa — Fonoaudiólogo(a)' },
{ value: 'CREFITO', label: 'CREFITO — Fisioterapeuta / T.O.' },
{ value: 'CRESS', label: 'CRESS — Assistente Social' },
{ value: 'CRN', label: 'CRN — Nutricionista' },
{ value: 'RMS', label: 'RMS — Residência Multiprofissional' },
{ value: 'outro', label: 'Outro' }
];
const UF_OPTIONS = [
'AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB',
'PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO'
].map(uf => ({ value: uf, label: uf }));
function goSeguranca() {
// Página nativa Melissa não vaza pra layout clássico
router.push('/melissa/seguranca');
}
// Troca de layout variant (classic/rail/melissa). Confirma + persiste +
// hard reload sair do shell Melissa requer reload pq AppLayout não tem
@@ -134,7 +157,12 @@ const form = reactive({
social_instagram: '',
social_youtube: '',
social_facebook: '',
social_x: ''
social_x: '',
// Registro profissional (CFP #5 exigido pra emissão de recibos/laudos)
professional_registration_type: '',
professional_registration_type_other: '',
professional_registration_number: '',
professional_registration_uf: ''
});
const customSocials = ref([]);
@@ -345,7 +373,7 @@ async function loadProfile() {
const { data: prof, error: pErr } = await supabase
.from('profiles')
.select(
'role, full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom'
'role, full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom, professional_registration_type, professional_registration_type_other, professional_registration_number, professional_registration_uf'
)
.eq('id', user.id)
.maybeSingle();
@@ -364,6 +392,10 @@ async function loadProfile() {
form.social_youtube = prof.social_youtube ?? '';
form.social_facebook = prof.social_facebook ?? '';
form.social_x = prof.social_x ?? '';
form.professional_registration_type = prof.professional_registration_type ?? '';
form.professional_registration_type_other = prof.professional_registration_type_other ?? '';
form.professional_registration_number = prof.professional_registration_number ?? '';
form.professional_registration_uf = prof.professional_registration_uf ?? '';
customSocials.value = Array.isArray(prof.social_custom) ? prof.social_custom : [];
ui.avatarPreview = form.avatar_url;
}
@@ -430,7 +462,16 @@ async function saveAll() {
social_youtube: String(form.social_youtube || '').trim() || null,
social_facebook: String(form.social_facebook || '').trim() || null,
social_x: String(form.social_x || '').trim() || null,
social_custom: customSocials.value.filter((s) => s.name || s.url)
social_custom: customSocials.value.filter((s) => s.name || s.url),
// Registro profissional (CFP) null se vazio
professional_registration_type: String(form.professional_registration_type || '').trim() || null,
// type_other só preenchido quando type === 'outro' (limpa quando muda)
professional_registration_type_other:
form.professional_registration_type === 'outro'
? (String(form.professional_registration_type_other || '').trim() || null)
: null,
professional_registration_number: String(form.professional_registration_number || '').trim() || null,
professional_registration_uf: String(form.professional_registration_uf || '').trim() || null
};
const { data: updatedProfile, error: pErr2 } = await supabase
@@ -833,6 +874,94 @@ onBeforeUnmount(() => {
</div><!-- /.mpr-w__body -->
</div>
<!-- Registro Profissional (CFP #5) -->
<div id="mpr-sec-registro" class="mpr-w">
<div class="mpr-w__head">
<div class="mpr-w__icon"><i class="pi pi-id-card" /></div>
<div class="mpr-w__title">
<div class="mpr-w__title-text">Registro profissional</div>
<div class="mpr-w__sub">Conselho regional exigido para emissão de recibos, atestados e laudos</div>
</div>
</div>
<div class="mpr-w__body">
<div class="mpr-grid">
<div class="mpr-field mpr-field--half">
<FloatLabel variant="on">
<Select
id="mpr_reg_type"
v-model="form.professional_registration_type"
:options="REGISTRATION_TYPE_OPTIONS"
optionLabel="label"
optionValue="value"
class="w-full"
@change="markDirty"
/>
<label for="mpr_reg_type">Tipo de registro</label>
</FloatLabel>
<small class="mpr-hint">Conselho profissional ao qual você é vinculado.</small>
</div>
<!-- Campo livre quando tipo='outro' -->
<div v-if="form.professional_registration_type === 'outro'" class="mpr-field mpr-field--half">
<FloatLabel variant="on">
<InputText
id="mpr_reg_type_other"
v-model="form.professional_registration_type_other"
class="w-full"
@input="markDirty"
/>
<label for="mpr_reg_type_other">Nome do conselho/instituição *</label>
</FloatLabel>
<small class="mpr-hint">Ex: APM, ABRAP, etc.</small>
</div>
<div class="mpr-field mpr-field--half">
<FloatLabel variant="on">
<InputText
id="mpr_reg_number"
v-model="form.professional_registration_number"
class="w-full"
:disabled="!form.professional_registration_type"
@input="markDirty"
/>
<label for="mpr_reg_number">Número do registro</label>
</FloatLabel>
<small class="mpr-hint">Ex: 06/12345</small>
</div>
<div class="mpr-field mpr-field--half">
<FloatLabel variant="on">
<Select
id="mpr_reg_uf"
v-model="form.professional_registration_uf"
:options="UF_OPTIONS"
optionLabel="label"
optionValue="value"
:disabled="!form.professional_registration_type"
:filter="true"
class="w-full"
@change="markDirty"
/>
<label for="mpr_reg_uf">UF</label>
</FloatLabel>
<small class="mpr-hint">Estado do conselho.</small>
</div>
<div v-if="form.professional_registration_type && form.professional_registration_number" class="mpr-field mpr-field--full">
<div class="mpr-preview-box">
<span class="mpr-preview-label">Aparecerá nos documentos como:</span>
<strong class="mpr-preview-value">
{{ form.professional_registration_type === 'outro'
? (form.professional_registration_type_other || 'Conselho não informado')
: form.professional_registration_type }}
{{ form.professional_registration_number }}{{ form.professional_registration_uf ? '/' + form.professional_registration_uf : '' }}
</strong>
</div>
</div>
</div>
</div><!-- /.mpr-w__body -->
</div>
<!-- Contato -->
<div id="mpr-sec-contato" class="mpr-w">
<div class="mpr-w__head">
@@ -1041,7 +1170,7 @@ onBeforeUnmount(() => {
</div>
<div>
<div class="mpr-lv-name">Rail</div>
<div class="mpr-lv-sub">Mini rail + painel expansível, full-width</div>
<div class="mpr-lv-sub">Ícones no canto esquerdo + painel expansível. Disponível apenas no desktop.</div>
</div>
</div>
</button>
@@ -1069,6 +1198,91 @@ onBeforeUnmount(() => {
</div>
</div><!-- /.mpr-w__body -->
</div>
<!-- Preferências (tema + aparência) -->
<div id="mpr-sec-preferencias" class="mpr-w">
<div class="mpr-w__head">
<div class="mpr-w__icon"><i class="pi pi-palette" /></div>
<div class="mpr-w__title">
<div class="mpr-w__title-text">Preferências</div>
<div class="mpr-w__sub">Aparência do sistema</div>
</div>
</div>
<div class="mpr-w__body">
<div class="mpr-grid">
<div class="mpr-field mpr-field--full">
<label class="mpr-label">Tema</label>
<div class="mpr-theme-row">
<button
type="button"
class="mpr-lv-card mpr-theme-card"
:class="{ 'mpr-lv-card--current': !isDarkTheme }"
@click="isDarkTheme && toggleDarkMode()"
>
<i class="pi pi-sun mpr-theme-icon" style="color: #f59e0b;" />
<div class="mpr-theme-text">
<div class="mpr-lv-name">Claro</div>
<div class="mpr-lv-sub">Fundo branco</div>
</div>
<div class="mpr-lv-radio">
<div v-if="!isDarkTheme" class="mpr-lv-dot" />
</div>
</button>
<button
type="button"
class="mpr-lv-card mpr-theme-card"
:class="{ 'mpr-lv-card--current': isDarkTheme }"
@click="!isDarkTheme && toggleDarkMode()"
>
<i class="pi pi-moon mpr-theme-icon" style="color: #6366f1;" />
<div class="mpr-theme-text">
<div class="mpr-lv-name">Escuro</div>
<div class="mpr-lv-sub">Menos fadiga visual</div>
</div>
<div class="mpr-lv-radio">
<div v-if="isDarkTheme" class="mpr-lv-dot" />
</div>
</button>
</div>
<small class="mpr-hint">A preferência é salva no seu perfil.</small>
</div>
</div>
</div><!-- /.mpr-w__body -->
</div>
<!-- Segurança -->
<div id="mpr-sec-seguranca" class="mpr-w">
<div class="mpr-w__head">
<div class="mpr-w__icon"><i class="pi pi-shield" /></div>
<div class="mpr-w__title">
<div class="mpr-w__title-text">Segurança</div>
<div class="mpr-w__sub">Senha e proteção da conta</div>
</div>
</div>
<div class="mpr-w__body">
<div class="mpr-grid">
<div class="mpr-field mpr-field--full">
<div class="mpr-info-row">
<div class="mpr-info-text">
<div class="mpr-info-title">E-mail de acesso</div>
<div class="mpr-info-value">{{ userEmail }}</div>
</div>
<small class="mpr-hint mpr-info-hint">Para trocar o e-mail, contate o suporte.</small>
</div>
</div>
<div class="mpr-field mpr-field--full">
<button type="button" class="mpr-lv-card mpr-action-card" @click="goSeguranca">
<i class="pi pi-key mpr-action-icon" />
<div class="mpr-action-text">
<div class="mpr-lv-name">Trocar senha</div>
<div class="mpr-lv-sub">Atualize sua senha de acesso ao sistema</div>
</div>
<i class="pi pi-arrow-right mpr-action-arrow" />
</button>
</div>
</div>
</div><!-- /.mpr-w__body -->
</div>
</template>
</div>
</div>
@@ -1943,4 +2157,101 @@ onBeforeUnmount(() => {
line-height: 1.3;
margin-top: 2px;
}
/* ═══════════ Registro Profissional — preview box ═══════════ */
.mpr-preview-box {
background: color-mix(in srgb, var(--p-primary-color) 7%, transparent);
border: 1px solid color-mix(in srgb, var(--p-primary-color) 22%, var(--surface-border));
border-radius: 8px;
padding: 10px 14px;
font-size: 0.88rem;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.mpr-preview-label {
color: var(--text-color-secondary);
}
.mpr-preview-value {
color: var(--text-color);
font-weight: 600;
letter-spacing: 0.01em;
}
/* ═══════════ Preferências — tema em 1 linha ═══════════ */
.mpr-theme-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.mpr-theme-card {
padding: 14px 16px;
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
text-align: left;
}
.mpr-theme-icon {
font-size: 1.3rem;
flex-shrink: 0;
}
.mpr-theme-text {
flex: 1;
min-width: 0;
}
/* ═══════════ Segurança — info row + action card ═══════════ */
.mpr-info-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 14px;
border: 1px solid var(--surface-border);
border-radius: 8px;
background: var(--surface-ground, transparent);
}
.mpr-info-text {
flex: 1;
min-width: 0;
}
.mpr-info-title {
font-weight: 600;
margin-bottom: 4px;
color: var(--text-color);
}
.mpr-info-value {
color: var(--text-color-secondary);
font-size: 0.9rem;
}
.mpr-info-hint {
margin: 0;
flex-shrink: 0;
}
.mpr-action-card {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
width: 100%;
text-align: left;
cursor: pointer;
}
.mpr-action-icon {
font-size: 1.4rem;
color: var(--text-color);
flex-shrink: 0;
}
.mpr-action-text {
flex: 1;
min-width: 0;
}
.mpr-action-arrow {
opacity: 0.5;
flex-shrink: 0;
}
</style>
+1 -1
View File
@@ -125,7 +125,7 @@ function onClearBg() {
</script>
<template>
<div class="glass-panel mp-panel absolute top-12 right-0 w-72">
<div class="glass-panel mp-panel absolute bottom-12 right-0 w-72">
<!-- Cabecalho fixo -->
<header class="mp-head">
<div class="mp-head__title">
+72 -1
View File
@@ -36,7 +36,13 @@ const props = defineProps({
filtroTipo: { type: String, default: null }
});
const emit = defineEmits(['evento', 'clear-filter']);
const emit = defineEmits(['evento', 'clear-filter', 'iniciar-cronometro']);
// Helper exposto no template: mostra o botao so quando o evento esta
// em curso E tem patient_id (atividade livre/bloqueio nao tem paciente).
function podeIniciarCrono(ev) {
return isEvEmCurso(ev) && !!ev?.patient_id;
}
//
// Range de horas (HORA_INICIO/HORA_FIM) derivado de:
@@ -382,6 +388,19 @@ onMounted(() => {
:class="['tl-event-pill__status', statusIcon(ev)]"
aria-hidden="true"
/>
<!-- Botao overlay so em sessoes em curso com
paciente. stopPropagation pra nao disparar
o click do pill que abre o evento. -->
<button
v-if="podeIniciarCrono(ev)"
type="button"
class="tl-event-pill__crono"
title="Iniciar cronômetro"
aria-label="Iniciar cronômetro"
@click.stop="emit('iniciar-cronometro', ev)"
>
<i class="pi pi-stopwatch" />
</button>
</div>
<div
class="absolute top-0 h-full flex flex-col items-center pointer-events-none z-20"
@@ -459,6 +478,18 @@ onMounted(() => {
{{ fmtHora(ev.startH) }} {{ fmtHora(ev.endH) }}
</div>
<div class="vt-event-label">{{ ev.label }}</div>
<!-- Botao overlay so em sessoes em curso com paciente.
stopPropagation pra nao disparar o click do pill. -->
<button
v-if="podeIniciarCrono(ev)"
type="button"
class="vt-event__crono"
title="Iniciar cronômetro"
aria-label="Iniciar cronômetro"
@click.stop="emit('iniciar-cronometro', ev)"
>
<i class="pi pi-stopwatch" />
</button>
</div>
<div class="vt-now" :style="{ top: nowCursorTop }">
@@ -640,6 +671,46 @@ html:not(.app-dark) .tl-day-badge--feriado {
margin-left: 0;
}
/* Botao "Iniciar cronometro" overlay no canto sup. direito do
pill em sessoes em curso. Cor solida pra destacar contra o bg
colorido do evento; pulso sutil pra chamar atencao sem irritar. */
.tl-event-pill__crono,
.vt-event__crono {
position: absolute;
top: 3px;
right: 3px;
width: 22px;
height: 22px;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.45);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 9999px;
cursor: pointer;
font-family: inherit;
padding: 0;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
transition: background-color 160ms ease, transform 160ms ease, border-color 160ms ease;
animation: tl-crono-pulse 2s ease-in-out infinite;
z-index: 2;
}
.tl-event-pill__crono:hover,
.vt-event__crono:hover {
background: rgba(16, 185, 129, 0.85); /* emerald-500 — convida ao "play" */
border-color: rgba(255, 255, 255, 0.6);
transform: scale(1.08);
animation-play-state: paused;
}
.tl-event-pill__crono > i,
.vt-event__crono > i {
font-size: 0.7rem;
}
@keyframes tl-crono-pulse {
0%, 100% { box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35), 0 0 0 0 rgba(16, 185, 129, 0.45); }
50% { box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35), 0 0 0 6px rgba(16, 185, 129, 0); }
}
/* Realizado: glow verde sutil (cor do bg ja eh verde) — celebra o feito */
.tl-pill--realizado {
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.28), 0 4px 12px rgba(16, 185, 129, 0.18);
@@ -79,6 +79,15 @@ export const MELISSA_CONFIG_GRUPOS = [
{ key: 'cfg-email-templates', label: 'Templates de E-mail', desc: 'Personalize os e-mails enviados aos pacientes.', icon: 'pi pi-envelope' }
]
},
{
key: 'documentos',
label: 'Documentos',
desc: 'Modelos, assinaturas e configurações da geração de documentos.',
icon: 'pi pi-file',
items: [
{ key: 'documentos-templates', label: 'Modelos de documentos', desc: 'Cadastre e edite templates de recibos, atestados, laudos, TCLE, LGPD e mais.', icon: 'pi pi-file-edit' }
]
},
{
key: 'plataforma',
label: 'Plataforma',
+2 -2
View File
@@ -73,8 +73,8 @@ export default function adminMenu(ctx = {}) {
{ label: 'Tags', icon: 'pi pi-fw pi-tags', to: { name: 'admin-pacientes-tags' } },
{ label: 'Médicos & Referências', icon: 'pi pi-fw pi-heart', to: { name: 'admin-pacientes-medicos' } },
{ label: 'Link Externo', icon: 'pi pi-fw pi-link', to: { name: 'admin-pacientes-link-externo' } },
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: { name: 'admin-pacientes-recebidos' }, badgeKey: 'cadastrosRecebidos' },
{ label: 'Templates de Documentos', icon: 'pi pi-fw pi-file-edit', to: { name: 'admin-documents-templates' }, feature: 'documents.templates', proBadge: true }
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: { name: 'admin-pacientes-recebidos' }, badgeKey: 'cadastrosRecebidos' }
// "Templates de Documentos" movido pra Configurações
]
},
+1 -1
View File
@@ -45,7 +45,7 @@ export default [
{ label: 'Tags', icon: 'pi pi-tags', to: '/therapist/patients/tags' },
{ label: 'Médicos & Referências', icon: 'pi pi-heart', to: '/therapist/patients/medicos' },
{ label: 'Documentos', icon: 'pi pi-file', to: '/therapist/documents', feature: 'documents.upload' },
{ label: 'Templates', icon: 'pi pi-file-edit', to: '/therapist/documents/templates', feature: 'documents.templates', proBadge: true },
// "Templates" movido pra /configuracoes/documentos/templates — agora vive em Configurações
{ label: 'Meu link de cadastro', icon: 'pi pi-link', to: '/therapist/patients/link-externo' },
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: '/therapist/patients/cadastro/recebidos', badgeKey: 'cadastrosRecebidos' }
]
+2 -6
View File
@@ -161,12 +161,8 @@ export default {
// ======================================================
// 📄 DOCUMENTOS
// ======================================================
{
path: 'documents/templates',
name: 'admin-documents-templates',
component: () => import('@/features/documents/DocumentTemplatesPage.vue'),
meta: { feature: 'documents.templates' }
},
// /documents/templates removido — mudou pra /configuracoes/documentos/templates
// (setup/config, não operação clínica).
// ======================================================
// 🔐 SEGURANÇA
+11
View File
@@ -167,6 +167,17 @@ export default {
path: 'creditos-whatsapp',
name: 'ConfiguracoesCreditosWhatsapp',
component: () => import('@/layout/configuracoes/ConfiguracoesCreditosWhatsappPage.vue')
},
// ── Documentos & Templates ────────────────────────────────────
// Templates de documento (recibo, atestado, laudo, TCLE, LGPD etc).
// Antes vivia em /therapist/documents/templates — movido pra
// Configurações por ser setup, não operação clínica.
{
path: 'documentos/templates',
name: 'ConfiguracoesDocumentosTemplates',
component: () => import('@/features/documents/DocumentTemplatesPage.vue'),
meta: { feature: 'documents.templates' }
}
]
};
+2 -6
View File
@@ -147,12 +147,8 @@ export default {
component: () => import('@/features/documents/DocumentsListPage.vue'),
meta: { feature: 'documents.upload' }
},
{
path: 'documents/templates',
name: 'therapist-documents-templates',
component: () => import('@/features/documents/DocumentTemplatesPage.vue'),
meta: { feature: 'documents.templates' }
},
// /documents/templates removido — mudou pra /configuracoes/documentos/templates
// (setup/config, não operação clínica).
{
path: 'patients/:id/documents',
name: 'therapist-patient-documents',
+158 -28
View File
@@ -148,7 +148,7 @@ export async function loadTherapistData() {
const { data: profile } = await supabase
.from('profiles')
.select('full_name, phone, professional_registration_type, professional_registration_number, professional_registration_uf')
.select('full_name, phone, professional_registration_type, professional_registration_type_other, professional_registration_number, professional_registration_uf')
.eq('id', ownerId)
.single();
@@ -156,7 +156,10 @@ export async function loadTherapistData() {
const { data: userData } = await supabase.auth.getUser();
const email = userData?.user?.email || '';
const tipo = profile?.professional_registration_type || '';
const tipoRaw = profile?.professional_registration_type || '';
const tipoOther = profile?.professional_registration_type_other || '';
// Quando type='outro', usa o nome livre do conselho/instituição
const tipo = tipoRaw === 'outro' ? tipoOther : tipoRaw;
const numero = profile?.professional_registration_number || '';
const uf = profile?.professional_registration_uf || '';
const registro = formatRegistroProfissional({ tipo, numero, uf });
@@ -173,7 +176,7 @@ export async function loadTherapistData() {
// o número/UF (sem prefixo) pra não duplicar com o "CRP" já no HTML.
// Quando o registro não é CRP, retorna vazio (template visualmente errado
// pede pra usar {{terapeuta_registro}}).
terapeuta_crp: tipo === 'CRP' ? (uf ? `${numero}/${uf}` : numero) : ''
terapeuta_crp: tipoRaw === 'CRP' ? (uf ? `${numero}/${uf}` : numero) : ''
};
}
@@ -240,23 +243,43 @@ export async function loadAllVariables(patientId, agendaEventoId = null, extras
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
const [patient, session, therapist, clinic] = await Promise.all([
// Promise.allSettled pra nao mascarar falha individual: se uma source
// falhar, as outras ainda preenchem e a gente loga qual quebrou.
const results = await Promise.allSettled([
loadPatientData(patientId),
loadSessionData(agendaEventoId),
loadTherapistData(),
loadClinicData(tenantId)
]);
const labels = ['patient', 'session', 'therapist', 'clinic'];
const errors = [];
results.forEach((r, i) => {
if (r.status === 'rejected') {
errors.push({ source: labels[i], err: r.reason });
console.error(`[loadAllVariables] falha em ${labels[i]}:`, r.reason);
}
});
const [patient, session, therapist, clinic] = results.map(r => r.status === 'fulfilled' ? r.value : {});
if (import.meta?.env?.DEV) {
console.log('[loadAllVariables] resultados:', {
patient, session, therapist, clinic,
ownerId, tenantId, patientId, agendaEventoId,
errors
});
}
// Resolve valor numérico (extras tem prioridade sobre session)
// Resolve valor numérico (extras tem prioridade sobre session).
// Number.isFinite (strict) em vez de isFinite global: este último coerce
// null pra 0 e retorna true, fazendo null.toFixed crashar logo abaixo.
const valorNum = extras.valor != null
? Number(extras.valor)
: (session.valor ? Number(String(session.valor).replace(/[R$\s.]/g, '').replace(',', '.')) : null);
const valorFormatted = isFinite(valorNum)
const valorFormatted = Number.isFinite(valorNum)
? `R$ ${valorNum.toFixed(2).replace('.', ',')}`
: (session.valor || '');
const valorExtensoStr = isFinite(valorNum) ? valorExtenso(valorNum) : '';
const valorExtensoStr = Number.isFinite(valorNum) ? valorExtenso(valorNum) : '';
const merged = {
...patient,
@@ -381,9 +404,10 @@ export async function printDocument(template, variables = {}) {
* @param {string} params.patientId
* @param {object} params.dadosPreenchidos - snapshot dos dados usados
* @param {Blob} params.pdfBlob - PDF gerado (opcional pode ser null se so print)
* @returns {object} registro criado
* @param {string} [params.editingDocId] - se setado, re-edita doc existente (UPDATE)
* @returns {object} registro criado/atualizado em document_generated
*/
export async function saveGeneratedDocument({ templateId, patientId, dadosPreenchidos, pdfBlob, templateNome, templateTipo }) {
export async function saveGeneratedDocument({ templateId, patientId, dadosPreenchidos, pdfBlob, templateNome, templateTipo, editingDocId = null }) {
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
@@ -405,27 +429,90 @@ export async function saveGeneratedDocument({ templateId, patientId, dadosPreenc
if (upErr) throw upErr;
}
// Registra na tabela document_generated
const { data, error } = await supabase
.from('document_generated')
.insert({
template_id: templateId,
patient_id: patientId,
tenant_id: tenantId,
dados_preenchidos: dadosPreenchidos || {},
pdf_path: pdfPath,
storage_bucket: BUCKET,
gerado_por: ownerId
})
.select('*')
.single();
// ─── MODO EDIT (UPDATE in-place) ─────────────────────────
// Re-edicao: preserva documents.id (e o audit trail), substitui o PDF
// no Storage, atualiza metadados. Best-effort cleanup do PDF antigo.
if (editingDocId) {
const { data: oldDoc } = await supabase
.from('documents')
.select('bucket_path, storage_bucket')
.eq('id', editingDocId)
.single();
if (error) throw error;
const docPatch = {
tipo_documento: mapTipoDocumento(templateTipo),
descricao: `Gerado a partir do template: ${templateNome || 'documento'}`
};
if (pdfPath) {
docPatch.bucket_path = pdfPath;
docPatch.storage_bucket = BUCKET;
docPatch.tamanho_bytes = pdfBlob?.size || null;
docPatch.nome_original = filename.replace(/_/g, ' ').replace('.pdf', '') + '.pdf';
}
const { error: upDocErr } = await supabase
.from('documents')
.update(docPatch)
.eq('id', editingDocId);
if (upDocErr) throw upDocErr;
// Registra na tabela documents para aparecer na lista do paciente
// Reutiliza o mesmo arquivo do bucket generated-docs (sem upload duplicado)
// Atualiza document_generated. Pode nao existir (docs legados sem
// linkage) — INSERT nesse caso, com documento_id apontando pro doc.
const { data: existingGen } = await supabase
.from('document_generated')
.select('id')
.eq('documento_id', editingDocId)
.maybeSingle();
let data;
if (existingGen) {
const genPatch = {
template_id: templateId,
dados_preenchidos: dadosPreenchidos || {}
};
if (pdfPath) genPatch.pdf_path = pdfPath;
const { data: updated, error: upGenErr } = await supabase
.from('document_generated')
.update(genPatch)
.eq('id', existingGen.id)
.select('*')
.single();
if (upGenErr) throw upGenErr;
data = updated;
} else {
const { data: inserted, error: insGenErr } = await supabase
.from('document_generated')
.insert({
template_id: templateId,
patient_id: patientId,
tenant_id: tenantId,
dados_preenchidos: dadosPreenchidos || {},
pdf_path: pdfPath,
storage_bucket: BUCKET,
gerado_por: ownerId,
documento_id: editingDocId
})
.select('*')
.single();
if (insGenErr) throw insGenErr;
data = inserted;
}
// Cleanup do PDF antigo no Storage. Falha silenciosa — arquivo orfao
// nao quebra nada, so ocupa espaco minimo.
if (oldDoc?.bucket_path && oldDoc.bucket_path !== pdfPath && oldDoc.storage_bucket) {
supabase.storage.from(oldDoc.storage_bucket).remove([oldDoc.bucket_path])
.catch((e) => console.warn('[saveGeneratedDocument] cleanup antigo falhou:', e));
}
return data;
}
// ─── MODO CREATE (insert) ────────────────────────────────
// Insere documents primeiro pra capturar o id e linkar em
// document_generated via documento_id (FK).
let documentoId = null;
if (pdfPath) {
await supabase
const { data: newDoc, error: insDocErr } = await supabase
.from('documents')
.insert({
owner_id: ownerId,
@@ -442,9 +529,52 @@ export async function saveGeneratedDocument({ templateId, patientId, dadosPreenc
visibilidade: 'privado',
status_revisao: 'aprovado',
uploaded_by: ownerId
});
})
.select('id')
.single();
if (insDocErr) throw insDocErr;
documentoId = newDoc?.id || null;
}
// Registra em document_generated com o linkage documento_id preenchido
const { data, error } = await supabase
.from('document_generated')
.insert({
template_id: templateId,
patient_id: patientId,
tenant_id: tenantId,
dados_preenchidos: dadosPreenchidos || {},
pdf_path: pdfPath,
storage_bucket: BUCKET,
gerado_por: ownerId,
documento_id: documentoId
})
.select('*')
.single();
if (error) throw error;
return data;
}
// ── Buscar generated existente pra modo edit ─────────────────
/**
* Busca o registro document_generated vinculado a um documents.id.
* Retorna template_id + dados_preenchidos pra pre-popular o dialog.
* Null se nao houver linkage (docs uploaded direto, sem template).
*/
export async function loadGeneratedFromDocId(documentoId) {
if (!documentoId) return null;
const { data, error } = await supabase
.from('document_generated')
.select('id, template_id, dados_preenchidos, pdf_path, gerado_em')
.eq('documento_id', documentoId)
.maybeSingle();
if (error) {
console.error('[loadGeneratedFromDocId]', error);
return null;
}
return data;
}
+43 -36
View File
@@ -44,49 +44,56 @@ async function getActiveTenantId(uid) {
/**
* Variaveis que podem ser usadas nos templates.
* Cada variavel tem: key, label (pt-BR), grupo.
* - key: chave usada em {{variavel}} no HTML do template
* - label: rótulo amigável (pt-BR)
* - grupo: agrupamento visual no editor
* - source: descrição de ONDE o dado é cadastrado (pra exibir como
* hint no dialog "Gerar documento" quando o campo vier vazio).
* É texto explicativo o map real de carregamento vive em
* DocumentGenerate.service.js (loadPatientData / loadTherapistData /
* loadClinicData / loadSessionData).
*/
export const TEMPLATE_VARIABLES = [
// Paciente
{ key: 'paciente_nome', label: 'Nome do paciente', grupo: 'Paciente' },
{ key: 'paciente_nome_social', label: 'Nome social', grupo: 'Paciente' },
{ key: 'paciente_cpf', label: 'CPF do paciente', grupo: 'Paciente' },
{ key: 'paciente_data_nascimento', label: 'Data de nascimento', grupo: 'Paciente' },
{ key: 'paciente_telefone', label: 'Telefone do paciente', grupo: 'Paciente' },
{ key: 'paciente_email', label: 'E-mail do paciente', grupo: 'Paciente' },
{ key: 'paciente_endereco', label: 'Endereço do paciente', grupo: 'Paciente' },
// Paciente — fonte: tabela patients
{ key: 'paciente_nome', label: 'Nome do paciente', grupo: 'Paciente', source: 'Paciente → nome completo' },
{ key: 'paciente_nome_social', label: 'Nome social', grupo: 'Paciente', source: 'Paciente → nome social' },
{ key: 'paciente_cpf', label: 'CPF do paciente', grupo: 'Paciente', source: 'Paciente → CPF' },
{ key: 'paciente_data_nascimento', label: 'Data de nascimento', grupo: 'Paciente', source: 'Paciente → data de nascimento' },
{ key: 'paciente_telefone', label: 'Telefone do paciente', grupo: 'Paciente', source: 'Paciente → telefone' },
{ key: 'paciente_email', label: 'E-mail do paciente', grupo: 'Paciente', source: 'Paciente → e-mail principal' },
{ key: 'paciente_endereco', label: 'Endereço do paciente', grupo: 'Paciente', source: 'Paciente → endereço/número/bairro/cidade/UF' },
// Sessao
{ key: 'data_sessao', label: 'Data da sessão', grupo: 'Sessão' },
{ key: 'hora_inicio', label: 'Hora início', grupo: 'Sessão' },
{ key: 'hora_fim', label: 'Hora fim', grupo: 'Sessão' },
{ key: 'modalidade', label: 'Modalidade (presencial/online)', grupo: 'Sessão' },
// Sessao — fonte: agenda_eventos (só preenche se houver sessao vinculada)
{ key: 'data_sessao', label: 'Data da sessão', grupo: 'Sessão', source: 'Agenda → sessão selecionada (data de início)' },
{ key: 'hora_inicio', label: 'Hora início', grupo: 'Sessão', source: 'Agenda → sessão selecionada (hora de início)' },
{ key: 'hora_fim', label: 'Hora fim', grupo: 'Sessão', source: 'Agenda → sessão selecionada (hora de fim)' },
{ key: 'modalidade', label: 'Modalidade (presencial/online)', grupo: 'Sessão', source: 'Agenda → sessão selecionada (modalidade)' },
// Terapeuta
{ key: 'terapeuta_nome', label: 'Nome do terapeuta', grupo: 'Terapeuta' },
{ key: 'terapeuta_registro', label: 'Registro profissional (ex: CRP 12345/SP)', grupo: 'Terapeuta' },
{ key: 'terapeuta_registro_tipo', label: 'Tipo do registro (CRP/CRM/CRFa…)', grupo: 'Terapeuta' },
{ key: 'terapeuta_registro_numero', label: 'Número do registro', grupo: 'Terapeuta' },
{ key: 'terapeuta_registro_uf', label: 'UF do registro', grupo: 'Terapeuta' },
{ key: 'terapeuta_crp', label: 'CRP (legacy — prefira terapeuta_registro)', grupo: 'Terapeuta' },
{ key: 'terapeuta_email', label: 'E-mail do terapeuta', grupo: 'Terapeuta' },
{ key: 'terapeuta_telefone', label: 'Telefone do terapeuta', grupo: 'Terapeuta' },
// Terapeuta — fonte: profiles (usuário logado) + auth.users
{ key: 'terapeuta_nome', label: 'Nome do terapeuta', grupo: 'Terapeuta', source: 'Perfil → nome completo' },
{ key: 'terapeuta_registro', label: 'Registro profissional (ex: CRP 12345/SP)', grupo: 'Terapeuta', source: 'Perfil → Registro Profissional (tipo + número/UF)' },
{ key: 'terapeuta_registro_tipo', label: 'Tipo do registro (CRP/CRM/CRFa…)', grupo: 'Terapeuta', source: 'Perfil → Registro Profissional → tipo' },
{ key: 'terapeuta_registro_numero', label: 'Número do registro', grupo: 'Terapeuta', source: 'Perfil → Registro Profissional → número' },
{ key: 'terapeuta_registro_uf', label: 'UF do registro', grupo: 'Terapeuta', source: 'Perfil → Registro Profissional → UF' },
{ key: 'terapeuta_crp', label: 'CRP (legacy — prefira terapeuta_registro)', grupo: 'Terapeuta', source: 'Perfil → só preenche se o tipo for "CRP"' },
{ key: 'terapeuta_email', label: 'E-mail do terapeuta', grupo: 'Terapeuta', source: 'Conta → e-mail de login' },
{ key: 'terapeuta_telefone', label: 'Telefone do terapeuta', grupo: 'Terapeuta', source: 'Perfil → telefone' },
// Clinica
{ key: 'clinica_nome', label: 'Nome da clínica', grupo: 'Clínica' },
{ key: 'clinica_endereco', label: 'Endereço da clínica', grupo: 'Clínica' },
{ key: 'clinica_telefone', label: 'Telefone da clínica', grupo: 'Clínica' },
{ key: 'clinica_cnpj', label: 'CNPJ da clínica', grupo: 'Clínica' },
// Clinica — fonte: tabela tenants (clinica ativa do usuário)
{ key: 'clinica_nome', label: 'Nome da clínica', grupo: 'Clínica', source: 'Configurações → Clínica → nome' },
{ key: 'clinica_endereco', label: 'Endereço da clínica', grupo: 'Clínica', source: 'Configurações → Clínica → logradouro/número/bairro/cidade/UF' },
{ key: 'clinica_telefone', label: 'Telefone da clínica', grupo: 'Clínica', source: 'Configurações → Clínica → telefone' },
{ key: 'clinica_cnpj', label: 'CNPJ da clínica', grupo: 'Clínica', source: 'Configurações → Clínica → CPF/CNPJ (preenche só se tiver 14 dígitos)' },
// Financeiro
{ key: 'valor', label: 'Valor (R$)', grupo: 'Financeiro' },
{ key: 'valor_extenso', label: 'Valor por extenso', grupo: 'Financeiro' },
{ key: 'forma_pagamento', label: 'Forma de pagamento', grupo: 'Financeiro' },
// Financeiro — fonte: sessão OU extras (passados pelo chamador)
{ key: 'valor', label: 'Valor (R$)', grupo: 'Financeiro', source: 'Sessão (preço) ou informe manualmente' },
{ key: 'valor_extenso', label: 'Valor por extenso', grupo: 'Financeiro', source: 'Calculado a partir do valor' },
{ key: 'forma_pagamento', label: 'Forma de pagamento', grupo: 'Financeiro', source: 'Informe manualmente (PIX, dinheiro, cartão, etc)' },
// Datas
{ key: 'data_atual', label: 'Data atual', grupo: 'Datas' },
{ key: 'data_atual_extenso', label: 'Data atual por extenso', grupo: 'Datas' },
{ key: 'cidade_estado', label: 'Cidade/UF', grupo: 'Datas' }
// Datas — fonte: clock do sistema / endereço da clínica
{ key: 'data_atual', label: 'Data atual', grupo: 'Datas', source: 'Hoje (preenchido automaticamente)' },
{ key: 'data_atual_extenso', label: 'Data atual por extenso', grupo: 'Datas', source: 'Hoje (preenchido automaticamente)' },
{ key: 'cidade_estado', label: 'Cidade/UF', grupo: 'Datas', source: 'Configurações → Clínica → cidade/UF (últimos 2 elementos do endereço)' }
];
// ── List ────────────────────────────────────────────────────
+141 -3
View File
@@ -103,6 +103,12 @@ const form = reactive({
bio: '',
phone: '',
// Registro profissional (CFP #5 exigido pra emissão de recibos/laudos)
professional_registration_type: '',
professional_registration_type_other: '',
professional_registration_number: '',
professional_registration_uf: '',
site_url: '',
social_instagram: '',
social_youtube: '',
@@ -117,6 +123,24 @@ const form = reactive({
notify_news: false
});
// Opções do CHECK constraint da migration 20260521000003
const REGISTRATION_TYPE_OPTIONS = [
{ value: '', label: '— Não informado —' },
{ value: 'CRP', label: 'CRP — Psicólogo(a)' },
{ value: 'CRM', label: 'CRM — Médico(a)' },
{ value: 'CRFa', label: 'CRFa — Fonoaudiólogo(a)' },
{ value: 'CREFITO', label: 'CREFITO — Fisioterapeuta / T.O.' },
{ value: 'CRESS', label: 'CRESS — Assistente Social' },
{ value: 'CRN', label: 'CRN — Nutricionista' },
{ value: 'RMS', label: 'RMS — Residência Multiprofissional' },
{ value: 'outro', label: 'Outro' }
];
const UF_OPTIONS = [
'AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB',
'PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO'
].map(uf => ({ value: uf, label: uf }));
const customSocials = ref([]);
function addCustomSocial() {
@@ -611,7 +635,7 @@ async function loadProfile() {
const { data: prof, error: pErr } = await supabase
.from('profiles')
.select(
'role, full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom, language, timezone, notify_system_email, notify_reminders, notify_news'
'role, full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom, language, timezone, notify_system_email, notify_reminders, notify_news, professional_registration_type, professional_registration_type_other, professional_registration_number, professional_registration_uf'
)
.eq('id', user.id)
.maybeSingle();
@@ -631,6 +655,11 @@ async function loadProfile() {
form.social_facebook = prof.social_facebook ?? '';
form.social_x = prof.social_x ?? '';
form.professional_registration_type = prof.professional_registration_type ?? '';
form.professional_registration_type_other = prof.professional_registration_type_other ?? '';
form.professional_registration_number = prof.professional_registration_number ?? '';
form.professional_registration_uf = prof.professional_registration_uf ?? '';
if (Array.isArray(prof.social_custom)) {
customSocials.value = prof.social_custom;
}
@@ -707,7 +736,17 @@ async function saveAll() {
notify_system_email: !!form.notify_system_email,
notify_reminders: !!form.notify_reminders,
notify_news: !!form.notify_news
notify_news: !!form.notify_news,
// Registro profissional (CFP) null se vazio
professional_registration_type: String(form.professional_registration_type || '').trim() || null,
// type_other só preenchido quando type === 'outro' (limpa quando muda)
professional_registration_type_other:
form.professional_registration_type === 'outro'
? (String(form.professional_registration_type_other || '').trim() || null)
: null,
professional_registration_number: String(form.professional_registration_number || '').trim() || null,
professional_registration_uf: String(form.professional_registration_uf || '').trim() || null
};
const { data: updatedProfile, error: pErr2 } = await supabase
@@ -715,7 +754,7 @@ async function saveAll() {
.update(profilePayload)
.eq('id', userId.value)
.select(
'id, role, full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom, language, timezone, notify_system_email, notify_reminders, notify_news, updated_at'
'id, role, full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom, language, timezone, notify_system_email, notify_reminders, notify_news, professional_registration_type, professional_registration_type_other, professional_registration_number, professional_registration_uf, updated_at'
)
.single();
@@ -1105,6 +1144,105 @@ onBeforeUnmount(() => {
</div>
</div>
<!-- REGISTRO PROFISSIONAL (CFP #5) -->
<div
id="registro-profissional"
class="pcard relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-6 scroll-mt-20 transition-[border-color] duration-[220ms]"
style="--c: #0ea5e9; --c-dim: rgba(14, 165, 233, 0.08); --c-border: rgba(14, 165, 233, 0.2)"
>
<div class="pcard__shine" />
<div class="flex items-center gap-2.5 mb-3.5">
<div class="w-9 h-9 rounded-[0.625rem] shrink-0 flex items-center justify-center" style="background: var(--c-dim); border: 1px solid var(--c-border); color: var(--c)"><i class="pi pi-id-card" /></div>
<span class="text-[1rem] font-extrabold uppercase tracking-[0.14em] opacity-75" style="color: var(--c)">Registro Profissional</span>
</div>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] mb-1">Conselho regional</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">Exigido para emissão de recibos, atestados e laudos. Aparecerá no rodapé dos documentos.</div>
<div class="h-px bg-[var(--surface-border)] my-5" />
<div class="grid grid-cols-12 gap-4">
<!-- Tipo de conselho -->
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<Select
id="prof_registration_type"
v-model="form.professional_registration_type"
:options="REGISTRATION_TYPE_OPTIONS"
optionLabel="label"
optionValue="value"
class="w-full"
@update:modelValue="markDirty"
/>
<label for="prof_registration_type">Tipo de registro</label>
</FloatLabel>
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Conselho profissional ao qual você é vinculado.</div>
</div>
<!-- Campo livre quando tipo='outro' -->
<div v-if="form.professional_registration_type === 'outro'" class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<InputText
id="prof_registration_type_other"
v-model="form.professional_registration_type_other"
class="w-full"
@input="markDirty"
/>
<label for="prof_registration_type_other">Nome do conselho/instituição <span class="text-red-400">*</span></label>
</FloatLabel>
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Ex: APM (Associação Paulista de Medicina), ABRAP, etc.</div>
</div>
<!-- Número -->
<div class="col-span-7 md:col-span-3">
<FloatLabel variant="on">
<InputText
id="prof_registration_number"
v-model="form.professional_registration_number"
class="w-full"
:disabled="!form.professional_registration_type"
@input="markDirty"
/>
<label for="prof_registration_number">Número</label>
</FloatLabel>
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Ex: 06/12345</div>
</div>
<!-- UF -->
<div class="col-span-5 md:col-span-3">
<FloatLabel variant="on">
<Select
id="prof_registration_uf"
v-model="form.professional_registration_uf"
:options="UF_OPTIONS"
optionLabel="label"
optionValue="value"
:disabled="!form.professional_registration_type"
class="w-full"
:filter="true"
@update:modelValue="markDirty"
/>
<label for="prof_registration_uf">UF</label>
</FloatLabel>
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Estado do conselho.</div>
</div>
<!-- Preview -->
<div v-if="form.professional_registration_type && form.professional_registration_number" class="col-span-12">
<div class="rounded-md border border-[var(--c-border)] bg-[var(--c-dim)] p-3 text-[0.9rem]">
<span class="text-[var(--text-color-secondary)] mr-2">Aparecerá nos documentos como:</span>
<strong class="text-[var(--text-color)]">
{{ form.professional_registration_type === 'outro'
? (form.professional_registration_type_other || 'Conselho não informado')
: form.professional_registration_type }}
{{ form.professional_registration_number }}{{ form.professional_registration_uf ? '/' + form.professional_registration_uf : '' }}
</strong>
</div>
</div>
</div>
</div>
<!-- 02 SITES E REDES SOCIAIS -->
<div
id="redes-sociais"