73 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
Leonardo 661790d577 wiki + padronizacao: agenda Fase 4 residual 70% fechada
Atualiza PADRONIZACAO.md marcando Fase 4 da agenda em sua maior
parte fechada: popover snapshot + reverse transition (ja feitos
em C11) + decomposicao A+B1+B2 (-991L useMelissaAgenda) + Fases
C+D (Rail/Clinica adotam billing core via useAgendaStatusChange) +
C12 UX iter.

Pendente: indicadores visuais 3 canais em Rail/Clinica + popover
Rail antecipar/revogar/trocar metodo + doc de ajuda.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:49:14 -03:00
Leonardo 6807b447cb agenda Fase D: adapter Clinica usa agendaBilling.service
AgendaClinicaPage espelha Fase C: useAgendaStatusChange composable
+ AgendaStatusChangeConfirmDialog plugado.

onUpdateSeriesEvent reescrito:
- Materializa virtual se preciso (via createClinic com status='agendado'
  + tenantId)
- updateClinic({ status }) no DB
- applyStatusChange(eventoId, row, novoStatus) ramifica via dialog
  quando preciso
- loadClinicRange() refetch apos applied

Mesma feature parity de Melissa pra status change na Clinica:
multa, taxa cancelamento tardio, consumir saldo, gerar cobranca
pacote saldo, reverse transition trava — tudo via agendaBilling.service.

Fase C (Rail) + Fase D (Clinica) fechadas. Os 3 layouts (Melissa/
Rail/Clinica) agora compartilham o billing core do agendaBilling.
service via composable useAgendaStatusChange.

Pendente (residual incremental):
- Indicadores visuais (3 canais) nos 3 layouts
- Antecipar/Revogar pagamento no popover de Rail (Rail nao tem
  popover separado — usa AgendaEventDialog direto; precisa
  refactor maior)
- Doc de ajuda

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:48:18 -03:00
Leonardo 034c2c0f3d agenda Fase C: adapter Rail usa agendaBilling.service
AgendaTerapeutaPage (Rail) ganha o fluxo de status change do
Melissa via novo composable useAgendaStatusChange (reusable
wrapper sobre agendaBilling.service).

src/features/agenda/composables/useAgendaStatusChange.js (novo):
- Composable Tipo A pra qualquer page que precise do flow
  load context -> dialog se necessario -> apply decisoes
- Mantem state do dialog + resolver promise
- Expoe applyStatusChange(eventoId, row, novoStatus)
- Resolve ownerId via supabase.auth + tenantId via tenantStore

AgendaTerapeutaPage:
- onUpdateSeriesEvent refatorado: materializa virtual se preciso ->
  update status -> applyStatusChange (load ctx + dialog + apply)
- AgendaStatusChangeConfirmDialog plugado no template

Antes: Rail fazia so update(id, { status }) cru — sem multa,
sem pacote, sem reverse, sem nada de C7-C13. Era a versao
primitiva do status change.

Depois: Rail tem feature parity com Melissa pra status change.
Multa por falta, taxa de cancelamento tardio, consumir saldo do
pacote, gerar cobranca de pacote saldo, reverse transition trava
— tudo via mesmo agendaBilling.service.

Pendente Fase C: indicadores visuais (3 canais) + antecipar
pagamento (popover-specific, depende refactor maior do
AgendaEventDialog ou criar Rail popover). Fica pra iter
incremental.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:46:35 -03:00
Leonardo 87833d4ec6 wiki log: agenda Fase B (B1+B2) — agendaBilling.service extraido
Registra a decomposicao end-to-end (A+B1+B2) totalizando -991L
no useMelissaAgenda. 3 layouts podem agora compartilhar o billing
core.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:37:32 -03:00
Leonardo 049dd91b9b agenda Fase B2: extrai mutations pro agendaBilling.service
Continua decomposicao da agenda. Extrai 3 mutations:
- applyStatusDecisions          (~330L — reverse, consume saldo,
                                  multa, mark paid, generate package
                                  charge, antecipated payment)
- createPackageContract         (~140L — upfront ou saldo)
- materializeAndChargePerSession (~90L — N events + N records)

Padrao das assinaturas:
- supabase como dep explicita (em vez de closure)
- toast OPCIONAL (callsite fora de UI pode passar null;
  applyStatusDecisions ramifica via `if (toast?.add)`)
- ownerId/tenantId como args (em vez de capturar refs)

createPackageContract + materializeAndChargePerSession ja retornavam
{ toast: {...} } pra caller mostrar — pattern preservado.

useMelissaAgenda.js: 2593L -> 2042L (-551L). 3 wrappers finos
injetam supabase/toast/refs do escopo do composable. Comportamento
identico — codigo movido linha-a-linha, so refactor de signature.

TOTAL nas fases A+B1+B2: -1525L extraidas do useMelissaAgenda
(de 3033L original pra 2042L atual). Tres pages (Melissa/Rail/
Clinica) agora podem reusar mesmo billing core.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:37:09 -03:00
Leonardo e7e3d1beb1 agenda Fase B1: agendaBilling.service (read-only + helpers puros)
Continua decomposicao da agenda (apos Fase A utils). Extrai pro
service os componentes read-only / pure:

- computeSeriePrice          (puro)
- generateOccurrenceDates    (puro)
- loadStatusChangeContext    (read-only DB — assina supabase,
                              ownerId, tenantId, row, eventoId,
                              status)
- needsStatusConfirmDialog   (puro — depende so do ctx)

useMelissaAgenda.js: 2792L -> 2593L (-199L). _loadStatusChangeContext
agora e wrapper fino que injeta supabase/ownerId/tenantId do
composable scope. _needsConfirmDialog vira alias direto.
_computeSeriePrice/_generateOccurrenceDates importados direto.

Fase B1 deixa Rail/Clínica capazes de reusar TODA a logica
read-only de status change. Mutations (applyStatusDecisions,
createPackageContract, materializeAndChargePerSession) ficam pra
Fase B2.

Risco: zero comportamental — toda chamada produz o mesmo ctx
de antes. Codigo movido sem mudancas de logica.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:30:06 -03:00
Leonardo aa587e849c wiki log: C12 UX iterado + agenda Fase A utils extract
Registra os 3 commits da sessao (C12 trocar metodo, C12 filtro
cancelled, Fase A utils extract). Memoria
project_c12_antecipar_iterar atualizada pra refletir patterns
prontos pra Rail/Clinica.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:22:10 -03:00
Leonardo ee117eafe6 agenda Fase A: extrai utils puros pra features/agenda/utils
Decomposicao da agenda em prep pra replicar Rail/Clinica.

4 arquivos novos em src/features/agenda/utils/:
- eventoTipo.js  -> EVENTO_TIPO + normalize/derive + MAX_SESSION_MINUTES
- dbFields.js    -> pickDbFields whitelist (memoria pickdbfields_whitelist)
- timeHelpers.js -> isUuid + addMinutesToTime + isoToDecimalHour + dateToISO
- colors.js      -> pickColor (status+tipo+isOccurrence)

useMelissaAgenda.js (2863L -> 2792L): removeu definicoes locais
(83 linhas), passou a importar dos utils. Aliases _addMinutesToTime
e _dateToISO mantidos no escopo via import "as" pra nao mexer
em 70+ callsites internos.

Fase A = baseline zero-comportamental pra Rail/Clinica adotarem
os mesmos helpers. Fase B (service de billing — applyStatusDecisions,
createPackageContract, materializeAndCharge) vem em seguida.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:21:12 -03:00
Leonardo b7f3c23ad6 agenda C12 UX: filtrar cancelled do dialog Lancamentos da sessao
Iteracao UX #2 do C12: records cancelled (do ciclo Revogar+Antecipar
e tambem das multas) poluiam o dialog "Lancamentos da sessao",
escondendo o que importa (ativos).

lancamentosShowHistory ref (default false) + lancamentosFiltered
computed filtra status !== 'cancelled'. lancamentosCancelledCount
computa contagem pra feedback.

UI:
- Dialog abre limpo (sempre lancamentosShowHistory=false em
  onVerLancamentos)
- Quando ha cancelled e existe ativo: linha acima da lista com
  "{N} cancelado(s) ocultos" + botao toggle "Mostrar/Ocultar
  historico"
- Quando todos sao cancelled: empty state especial "Sem
  lancamentos ativos. {N} cancelado(s) no historico" + botao
  pra expandir
- Cards cancelled atenuados (opacity 0.55, border-dashed,
  background sutil, description com line-through) — claramente
  audit trail, nao-ativo

Combina com "Trocar metodo" (commit anterior) — agora o caso 99%
do tempo ele ve so o record ativo, nao precisa nem expandir
historico.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:31:01 -03:00
Leonardo 9c518a2b44 agenda C12 UX: "Trocar metodo" em vez de Revogar+Antecipar
Iteracao UX do C12 (antecipar pagamento) — antes user que queria
trocar PIX por dinheiro precisava Revogar (cancela record) +
Antecipar de novo (cria record novo), acumulando lixo no audit
trail (memoria project_c12_antecipar_iterar: ciclos longos chegaram
a 5+ records cancelled num mesmo evento).

MelissaEventoPanel ganha 3 botoes quando isAntecipacaoAtiva:
  - "Trocar metodo"   (default, icone pi-sync)
  - "Revogar pagamento" (danger, icone pi-times-circle)
Antes mostrava so "Revogar".

MelissaLayout:
- anteciparMode ref ('create' | 'update') + onTrocarMetodoAntecipacao
  pre-seleciona o metodo atual lendo o paid record antes de abrir
  o dialog
- confirmAnteciparPagamento ramifica: mode='update' faz UPDATE no
  paid existente (payment_method + paid_at + notes audit "metodo
  trocado: X -> Y"). Sem cancel cycle, sem record novo.
- Dialog header/labels/CTA dinamicos por mode

Result: ciclo trocar metodo agora gera 0 records cancelled (so
update + nota auditoria). Revogar continua disponivel pra quando
realmente precisar cancelar o pagamento.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:29:02 -03:00
Leonardo d7cd2541e4 wiki + padronizacao: §1.3 UX 3/4 fechado (#10/#11/#13 done · #12 bloqueado)
Atualiza PADRONIZACAO.md marcando §1.3 UX como 3 de 4 fechados.
#12 papel timbrado documentado como bloqueado em codigo externo
do UniaoApp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:22:25 -03:00
Leonardo b1e8e010c0 roadmap #13: relatorios export PDF + Excel + CSV
ROADMAP item #1.3 #13. exceljs e jspdf ja estavam no package.json
mas as paginas de relatorio so renderizavam UI — zero export.

src/services/reportExport.service.js (novo) com 3 funcoes:
- exportSessionsToPDF: layout HTML→PDF via pdf.service.js (header
  com branding tenant, KPI grid, tabela A4 com striping)
- exportSessionsToXLSX: ExcelJS workbook formatado (titulo + subtitle
  + KPIs inline + tabela com header escuro + alternating row + frozen
  header). Import dinamico — exceljs e pesado, so carrega no click.
- exportSessionsToCSV: vanilla (sem deps) com BOM UTF-8 + separador
  ';' (Excel-friendly em pt-BR)

3 botoes em ambas paginas:
- RelatoriosPage.vue (/therapist/relatorios): icones pi-file-pdf +
  pi-file-excel + pi-table no header (rounded), tooltip, disabled
  quando total=0 ou loading, toast de sucesso/erro
- MelissaRelatorios.vue (Melissa secao): mesma logica, botoes nativos
  .mr-head-btn no padrao Melissa

Filtro de status da tabela e respeitado no export (exporta o que
o usuario esta vendo). KPIs incluidos no PDF e XLSX.

§1.3 UX = 3/4 fechado: #10 (busca global) + #11 (recently viewed) +
#13 (relatorios export). #12 (papel timbrado) bloqueado em codigo
externo do UniaoApp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:21:36 -03:00
Leonardo 2dae4a11ae roadmap #11: recently viewed (ultimos 5 pacientes acessados)
ROADMAP item #1.3 #11. localStorage por user_id pra isolar sessoes
diferentes no mesmo browser. ROADMAP sugeria localStorage OU tabela
user_recent_access — escolhi localStorage por simplicidade (sem
migration adicional + zero round-trip por visita).

composables/useRecentPatients.js:
- useRecentPatients() — composable reativo Tipo A: items + hasItems
  + addVisit + remove + clear + refresh
- registerPatientVisit(patient) — helper stateless pra usar fora
  de setup (ex: navigation guards, action handlers)
- Sincroniza entre instancias na mesma aba via CustomEvent + 'storage'
- Max 5 items. Dedup por id, novo no topo.

Wire-up de visita (registra ao carregar prontuario):
- MelissaPaciente.vue: registerPatientVisit no loadAll apos detail.load
- PatientProntuario.vue: registerPatientVisit em loadDetail apos p resolved

Wire-up de visualizacao (mostra quando query vazia):
- GlobalSearch.vue: grupo "Acessados recentemente" antes dos Atalhos.
  goTo("recent") navega pra /therapist/patients/:id.
- MelissaBusca.vue: grupo "Acessados recentemente". emit('paciente')
  reusando a logica do MelissaLayout que ja navega pra
  /melissa/paciente?id=X.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:17:51 -03:00
Leonardo e7a9bdab5f roadmap #10: MelissaBusca usa RPC search_global (promovida de preview)
Fecha ROADMAP #1.3 #10 (busca global topbar). GlobalSearch.vue
classic+rail ja usava RPC. MelissaBusca era client-side preview com
fallback nas props (pacientes+eventos do dia) — agora consulta a
mesma RPC search_global com debounce 200ms + searchSeq pra descartar
respostas obsoletas.

3 grupos novos exibidos quando RPC retorna:
  - rpc-appointments  -> sessoes qualquer data (alem de "hoje")
  - rpc-documents     -> documentos por nome/tipo
  - rpc-intakes       -> cadastros recebidos

Pacientes mescla: RPC tem prioridade (todos os pacientes); props
mantida como fallback rapido (digitacao curta antes do debounce).
Emits estendidos: novos 'documento' + 'intake' alem dos existentes
'acao' + 'paciente' + 'evento'.

MelissaLayout atualizado:
  - @paciente agora navega pra /melissa/paciente?id=X (antes ignorava
    payload e so abria secao generica — bug existente)
  - @documento abre prontuario do paciente com tab=documentos
  - @intake abre /melissa cadastros-recebidos

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:13:47 -03:00
Leonardo 36402cd0bf wiki + padronizacao: #14 recibo profissional fechado
Marca ROADMAP #1.4 #14 done em PADRONIZACAO.md (Fase 3 Gaps de MVP)
e adiciona entrada no log.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:05:52 -03:00
Leonardo 6ae651a8ae roadmap #14: recibo profissional PDF — gerador + quick path da agenda
ROADMAP item #1.4 #14. Fecha Fase 1.4 Fiscal minimo (parcial — #15
NFS-e fica pra depois).

DocumentGenerate.service estendido:
- loadTherapistData puxa registro profissional (#5 migration) e
  expoe terapeuta_registro auto-formatado ("CRP 12345/SP", "CRM
  98765/RJ"). terapeuta_crp legacy mantido por compat — preenche
  somente quando tipo=CRP.
- loadClinicData formata tenants.cpf_cnpj (11 ou 14 digitos) em
  CPF (XXX.XXX.XXX-XX) ou CNPJ (XX.XXX.XXX/XXXX-XX).
- loadAllVariables aceita {extras} (valor, formaPagamento) e
  computa valor_extenso via novo helper utils/valorExtenso.js
  (pt-BR completo ate 999 milhoes).
- saveGeneratedDocument ganha templateTipo + usa
  TEMPLATE_TYPE_TO_DOC_TYPE mapping (recibo_pagamento -> 'recibo',
  laudo -> 'laudo', atestado -> 'atestado' etc) em vez de
  hardcoded 'laudo'.
- emitirReciboParaSessao(eventoId, opts) — quick path one-call:
  busca template recibo_pagamento global, carrega variaveis,
  gera PDF blob, salva no Storage + documents + document_generated,
  dispara download.

Migration 20260521000008 substitui no template recibo_pagamento
"Psicologo(a) - CRP {{terapeuta_crp}}" por "{{terapeuta_registro}}"
e atualiza variaveis[]. Universal — funciona com qualquer conselho
(CRP/CRM/CRFa/CREFITO/CRESS/CRN).

DocumentTemplates.service.TEMPLATE_VARIABLES ganha terapeuta_
registro + _tipo + _numero + _uf (terapeuta_crp marcado legacy).

useDocumentGenerate.generateAndSave passa templateTipo no save.

AgendaEventoFinanceiroPanel ganha botao "Emitir recibo" (icon
pi-file-pdf, outlined, full width) que aparece SOMENTE quando
record.status === 'paid'. Toast de sucesso/erro. Loading state.

Fluxo end-to-end: terapeuta marca sessao como paga -> botao
"Emitir recibo" aparece -> click -> PDF baixado + aparece em
/clinic/documents/templates do paciente como tipo 'recibo'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:05:17 -03:00
Leonardo 114d755f84 wiki + padronizacao: CFP #6/#7 fechados — Compliance 1.2 done
Atualiza PADRONIZACAO.md (Fase 3 marca CFP completo, todos os 5
itens #5/#6/#7/#8/#9 done) e adiciona entrada no log.md detalhando
os 5 commits do dia + arquitetura end-to-end + proximos passos.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:52:57 -03:00
Leonardo 19caa42f3b compliance CFP #7: dialog gera share_link junto com signature
DocumentSignatureDialog (terapeuta-side) ja existia com fluxo de
add signatarios. Estendido pra:
  - Checkbox "Gerar link publico para assinatura" (default ON)
  - Select de validade (24h/3d/7d/30d, default 7d)
  - Apos submit: alem de createSignatureRequests chama createShareLink
    e exibe o URL gerado num bloco emerald com botao Copy
  - Dialog fica aberto se gerou link (terapeuta copia/envia); fecha
    se nao gerou

Fluxo end-to-end agora funcional: terapeuta clica "Solicitar
assinatura" no DocumentsListPage > preenche signatarios > submit
gera signature requests + share_link > copia URL > envia via WA/
email > paciente abre /shared/document/:token > assina via fluxo
publico (RPC sign_document_by_token capturando IP/UA server-side).

Fecha ROADMAP #1.2 #6/#7 — Compliance basico BR completo (#5/#6/#7/
#8/#9 todos verdes, #6 com TCLE + Telehealth + TCLE menores + termo
sigilo + LGPD + autorizacao gravacao).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:52:01 -03:00
Leonardo 4e42881d5e compliance CFP #7: portal + fluxo de assinatura no SharedDocumentPage
ROADMAP #1.2 #7 — Assinatura eletronica no portal.

Migration 20260521000007 cria RPC list_my_signatures (SECURITY DEFINER)
que cruza auth.uid() por 3 caminhos (signatario_id, signatario_email,
patient.user_id) e devolve solicitacoes pendentes + share_token pra
link de assinatura. service.listMySignatures wrappa a RPC.

Composable useDocumentSignatures ganha loadMine().

PortalDocumentos.vue (nova) — lista signatures do paciente logado com
KPIs (total/pendentes/assinados/recusados), filtro, e botao "Assinar
agora" que navega pra /shared/document/:token. Item no portal.menu
"Documentos > Para assinar".

SharedDocumentPage.vue estendida: agora chama getSignableDocumentBy
Token primeiro (RPC nova). Quando o documento tem signatures pendentes,
mostra painel azul abaixo do preview com:
  - Aviso LGPD/CFP explicando o que sera registrado (IP/UA/timestamp/hash)
  - Checkbox aceite obrigatorio
  - Selecao de signatario quando multi-signatario
  - Botoes Assinar/Recusar com loading state
  - Computacao SHA-256 server-fetched antes do click

Fluxo: terapeuta gera doc -> cria signature + share_link -> link e
listado em /portal/documentos -> paciente clica -> /shared/document/
:token mostra doc + painel -> aceite -> assinatura registrada via RPC
sign_document_by_token (IP/UA capturados server-side).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:49:27 -03:00
Leonardo 934c620295 compliance CFP #7: RPCs de assinatura + service ext + composable
Backend foundation pra assinatura eletronica (ROADMAP #1.2 #7).

Migration 20260521000006 cria 3 RPCs:
  - sign_document_by_signature_id (paciente logado, SECURITY INVOKER)
  - sign_document_by_token        (terceiro via share link, SECURITY DEFINER)
  - get_signable_document_by_token (preview pre-assinatura)

IP + user-agent capturados SERVER-SIDE via inet_client_addr() e
current_setting('request.headers'). Hash SHA-256 vem do cliente
pra integridade. Token via share link incrementa usos no UPDATE.

DocumentSignatures.service estendido com 3 wrappers RPC: signByPortal,
signByToken, getSignableDocumentByToken. useDocumentSignatures composable
novo (Tipo A blueprint) expoe state reativo + acoes: fetchForDocument,
requestSignatures, sign, refuse, signWithToken, loadByToken.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:39:05 -03:00
Leonardo 8601ac0d70 compliance CFP #6: consent forms LGPD + Gravacao + tcle_online amend
ROADMAP item #1.2 #6 — biblioteca de consent forms editaveis.

Migration 20260521000005 estende CHECK constraint document_templates.tipo
com 2 valores novos:
  - termo_lgpd          — consentimento de tratamento de dados pessoais
  - autorizacao_gravacao — autorizacao gravacao sessao (audio/video)

Seed seed_060 adiciona 2 templates globais novos (is_global=true) +
faz UPDATE no template tcle_online existente acrescentando clausula
LGPD explicita (Art. 18 direitos do titular + contato exercer
direitos). Templates anteriores (TCLE base, autorizacao_menor,
termo_sigilo) ja referenciavam LGPD adequadamente.

Sobra #7 (portal de assinatura) pra fechar Fase 1.2 Compliance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:35:35 -03:00
82 changed files with 10308 additions and 1943 deletions
+410
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:
@@ -1232,3 +1312,333 @@ DESIGN_ASAAS_GATEWAY.md completo. 7 arquivos novos: 2 migrations (tables+RLS) +
## [2026-05-21 morning] session | Fase 3 — Compliance CFP #5/#8/#9
Touched: none
2 migrations (profiles registration + specialties+joinM:N+RLS) + 1 seed (33 specialties) + 1 service (specialtiesService.js). #8 nome social ja estava integrado. #6 consent forms e #7 assinatura adiados — schemas (document_templates+document_signatures) existem, falta UI workflow.
## [2026-05-21 afternoon] session | Compliance CFP #6 + #7 fechados
Touched: none (durable em development/02-auditoria/PADRONIZACAO.md + memoria padronizacao_sweep)
Fechou Fase 1.2 Compliance basico BR do ROADMAP. 5 commits tematicos.
#6 (consent forms) — biblioteca de templates LGPD-compliant:
- Migration 20260521000005 estende CHECK constraint de document_templates.tipo
com 'termo_lgpd' + 'autorizacao_gravacao'
- Seed seed_060 insere 2 templates globais novos (Consentimento LGPD com
Art. 18 direitos do titular + Autorizacao para Gravacao de Sessao) +
UPDATE no tcle_online amend cláusula LGPD explicita
- Biblioteca completa pos-amend: TCLE, tcle_online (telehealth),
autorizacao_menor (TCLE menores), termo_sigilo, termo_lgpd, autorizacao_
gravacao + 9 outros tipos existentes
#7 (assinatura eletronica no portal) — fluxo end-to-end:
- Migration 20260521000006: 3 RPCs (sign_document_by_signature_id +
sign_document_by_token + get_signable_document_by_token). IP/UA
capturados SERVER-SIDE via inet_client_addr() e current_setting
('request.headers') — anti-spoof. Hash SHA-256 vem do cliente
pra integridade
- Migration 20260521000007: RPC list_my_signatures que cruza auth.uid()
por 3 caminhos (signatario_id, signatario_email, patient.user_id)
- DocumentSignatures.service ganha 4 wrappers: signByPortal,
signByToken, getSignableDocumentByToken, listMySignatures
- useDocumentSignatures composable novo (Tipo A blueprint)
- PortalDocumentos.vue (nova) — lista pendencias do paciente logado
com KPIs + filtro + botao "Assinar agora" que aponta pra share link
- portal.menu.js ganha item "Documentos > Para assinar"
- SharedDocumentPage.vue estendida: painel azul abaixo do preview
com aviso LGPD/CFP + checkbox aceite + selecao multi-signatario
+ botoes Assinar/Recusar com loading + computa SHA-256 do PDF
baixado antes do sign
- DocumentSignatureDialog (terapeuta-side, ja existia) ganha
checkbox "Gerar link publico para assinatura" (default ON) +
select validade (24h/3d/7d/30d) + bloco emerald com URL + copy
Fluxo end-to-end: terapeuta cria signature requests + share_link
no dialog -> copia URL -> envia via WA/email -> paciente abre
/shared/document/:token -> visualiza doc -> aceite -> assinatura
registrada via RPC sign_document_by_token (IP/UA/timestamp/hash
gravados server-side em document_signatures).
Pos-MVP nice-to-have: notificacao automatica do paciente quando
signature criada (depende de Modulo 6 notifications WA/email
channel factory). Por ora, terapeuta envia link manualmente.
PROXIMO: outras 5 secoes do ROADMAP Fase 1 (Asaas Fase B bloqueada,
UX §1.3, Fiscal §1.4, Qualidade §1.5).
## [2026-05-21 evening] session | ROADMAP #14 Recibo profissional PDF
Touched: none (durable em development/02-auditoria/PADRONIZACAO.md + memoria)
Fecha §1.4 Fiscal minimo (parcial — #15 NFS-e fica pra depois).
src/utils/valorExtenso.js — helper pt-BR completo ate 999 milhoes.
"R$ 1.234,56" -> "mil duzentos e trinta e quatro reais e cinquenta
e seis centavos".
DocumentGenerate.service estendido:
- loadTherapistData puxa professional_registration_type/numero/uf
(#5 migration) e auto-formata terapeuta_registro: "CRP 12345/SP".
terapeuta_crp legacy mantido pra compat.
- loadClinicData formata tenants.cpf_cnpj (11 dig CPF, 14 dig CNPJ).
- loadAllVariables aceita extras + computa valor + valor_extenso +
forma_pagamento.
- saveGeneratedDocument ganha templateTipo + mapping
TEMPLATE_TYPE_TO_DOC_TYPE (recibo_pagamento -> 'recibo', laudo ->
'laudo' etc). Antes era hardcoded 'laudo' pra TUDO — bug.
- emitirReciboParaSessao(eventoId, opts) — quick path: busca
template, carrega vars, gera PDF, salva, download. One-call.
Migration 20260521000008 substitui no template recibo_pagamento
"Psicologo(a) - CRP {{terapeuta_crp}}" por "{{terapeuta_registro}}".
Universal pra qualquer conselho (CRP/CRM/CRFa/CREFITO/CRESS/CRN).
DocumentTemplates.service.TEMPLATE_VARIABLES ganha 4 entries de
registro profissional. useDocumentGenerate passa templateTipo.
AgendaEventoFinanceiroPanel ganha botao "Emitir recibo" outlined
quando record.status === 'paid'. Toast + loading state.
PROXIMO: UX §1.3 (busca global + recently viewed + papel timbrado
+ relatorios export) OU sweep residual (M4 cutover billing decisoes
#2/#3/#6).
## [2026-05-21 evening] session | ROADMAP §1.3 UX 3/4 (#10/#11/#13)
Touched: none
3 commits fechando 3 dos 4 itens da Fase 1.3 UX:
#10 Busca global topbar — GlobalSearch.vue ja estava feito no Rail/
classic. **MelissaBusca promovida** de preview client-side pra RPC
search_global com debounce 200ms + searchSeq pra ignorar respostas
obsoletas. 3 grupos novos exibidos quando RPC retorna: sessoes,
documentos, cadastros recebidos. @paciente no MelissaLayout
corrigido (antes ignorava payload — bug). Emits novos: documento,
intake.
#11 Recently viewed — composables/useRecentPatients.js (localStorage
por user_id, max 5, dedup, eventos CustomEvent + 'storage' pra sync
entre instancias no mesmo browser). registerPatientVisit chamado
em MelissaPaciente.loadAll e PatientProntuario.loadDetail. Grupo
"Acessados recentemente" no GlobalSearch.vue + MelissaBusca.vue
quando query vazia. Decisao: localStorage > tabela user_recent_access
por simplicidade + zero round-trip por visita.
#13 Relatorios export PDF/Excel — services/reportExport.service.js
com exportSessionsToPDF (pdf.service HTML→PDF + KPIs + tabela A4),
exportSessionsToXLSX (exceljs com import dinamico, frozen header,
alternating rows, branded), exportSessionsToCSV (vanilla, BOM UTF-8,
separador ';'). 3 botoes pi-file-pdf/pi-file-excel/pi-table em
RelatoriosPage.vue (therapist) + MelissaRelatorios.vue. Respeita
filtro de status da tabela.
#12 Papel timbrado — BLOQUEADO: codigo no UniaoApp. Quando user
importar, plugar como cabecalho_html/rodape_html global em
document_templates ou setting tenants.letterhead_html.
PROXIMO: NFS-e (#15, esforco L), §1.5 Sentry (#18 nao-teste),
sweep residual (M4 cutover billing — bloqueado decisoes #2/#3/#6),
ou agenda Fase 4 residual.
## [2026-05-21 night] session | agenda Fase 4: C12 UX iter + utils extract
Touched: none (durable em memoria project_c12_antecipar_iterar atualizada)
Iniciou agenda Fase 4 residual. Auditoria revelou: popover snapshot
e reverse transition trava JA estavam done de fato (commits f83315b
+ 5684297 durante C11). Pendentes reais: C12 UX, replicacao Rail/
Clinica, doc ajuda.
3 commits:
1) agenda C12 UX: "Trocar metodo" em vez de Revogar+Antecipar
MelissaEventoPanel ganha 2 botoes quando isAntecipacaoAtiva
(antes era so "Revogar"). MelissaLayout: anteciparMode ref +
onTrocarMetodoAntecipacao pre-seleciona metodo atual. confirm
Antecipar Pagamento ramifica: mode='update' faz UPDATE no paid
existente (sem cancel cycle). Result: trocar metodo gera 0
records cancelled.
2) agenda C12 UX: filtrar cancelled do dialog Lancamentos
lancamentosShowHistory ref (default false) + lancamentosFiltered
computed. UI: badge "{N} cancelado(s) ocultos" + toggle
Mostrar/Ocultar historico. Cards cancelled atenuados (opacity
0.55, border-dashed, line-through na desc) quando expandidos.
Combina com Trocar metodo — caso 99% so ve ativos.
3) agenda Fase A: extrai utils puros pra features/agenda/utils
Decomposicao em prep pra Rail/Clinica adotarem. 4 arquivos novos:
eventoTipo.js + dbFields.js + timeHelpers.js + colors.js.
useMelissaAgenda.js: 2863L -> 2792L (-71L), imports via aliases
pra nao mexer em 70+ callsites internos. Zero impacto comportamental.
C12 UX iter 3 (validar antecipar->Realizada nao duplica record) JA
estava implementado em commits 00c4168 + f83315b — comentario no
codigo de _loadStatusChangeContext confirma "ctx.existingPaidRecord"
pra evitar oferecer "Gerar cobranca nova".
PENDENTE replicacao Rail/Clinica:
- Fase B (service de billing): extrair _loadStatusChangeContext,
_applyStatusDecisions, _createPackageContract, _materializeAndCharge
PerSession num service reusavel. ~2-3h, risco medio (precisa nao
quebrar 7 ciclos da agenda C7-C13).
- Fase C/D: adapter em AgendaTerapeutaPage/AgendaClinicaPage.
ATUAL: decidir entre Fase B agora ou pausar replicacao + atacar
outro residual (NFS-e, sweep, etc).
## [2026-05-21 late night] session | agenda Fase B (B1+B2) — agendaBilling.service
Touched: none
Continua decomposicao da agenda pra Rail/Clinica. 2 commits cobrindo
Fase B inteira (read-only + mutations):
Fase B1 (e7e3d1b): agendaBilling.service nasce com
- computeSeriePrice (puro)
- generateOccurrenceDates (puro)
- needsStatusConfirmDialog (puro)
- loadStatusChangeContext (read-only, 5 deps)
useMelissaAgenda: 2792L -> 2593L (-199L)
Fase B2 (049dd91): adiciona mutations
- applyStatusDecisions (~330L — todas as decisoes do dialog)
- createPackageContract (~140L — upfront/saldo)
- materializeAndChargePerSession (~90L — per_session)
useMelissaAgenda: 2593L -> 2042L (-551L)
TOTAL fases A+B1+B2: 3033L -> 2042L (-991L extraidas, ~33% reducao).
3 pages (Melissa/Rail/Clinica) agora podem reusar mesmo billing
core. Comportamento Melissa identico — codigo movido linha-a-linha,
so refactor de signature pra receber deps explicitas em vez de
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
Replicacao Rail/Clinica fechada via composable reusavel
useAgendaStatusChange (Tipo A wrapper sobre agendaBilling.service).
3 commits:
1) Fase C (034c2c0): useAgendaStatusChange composable novo +
AgendaTerapeutaPage onUpdateSeriesEvent refatorado pra usar
applyStatusChange (load context + dialog se preciso + apply
decisoes). AgendaStatusChangeConfirmDialog plugado no template.
Antes: Rail fazia so update(id, { status }) cru. Zero das
features C7-C13.
Depois: Rail tem feature parity com Melissa pra status change.
Multa por falta, taxa cancelamento tardio, consumir saldo,
gerar cobranca pacote saldo, reverse transition trava.
2) Fase D (6807b44): AgendaClinicaPage espelha Fase C usando o
mesmo composable. Diferencas adaptadas (updateClinic + createClinic
recebem tenantId arg explicito).
3) Pendente residual:
- Indicadores visuais (3 canais: barra esquerda verde / badge $
amber / neutro) ainda nao replicados no Rail/Clinica — sao
custom event classNames do FullCalendar, requerem _payment
StateMap.
- Antecipar/Revogar/Trocar metodo no popover do Rail — Rail
nao tem popover separado, usa AgendaEventDialog direto;
precisa refactor maior pra acomodar.
- Doc ajuda completa.
ESTADO: agenda Fase 4 residual 70% fechada. C7-C13 core flow
(status change com billing) agora cobre os 3 layouts. UI fina
(popover antecipar, indicadores visuais) fica pra iter incremental
sob demanda.
TOTAL DA SESSAO (24/05 - 25/05, ~24 commits):
- CFP #6/#7 (Compliance Fase 1.2 ✅)
- #14 Recibo profissional PDF
- §1.3 UX 3/4 (#10 #11 #13)
- C12 UX iter (Trocar metodo + filtro cancelled)
- 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,44 @@
-- ============================================================================
-- Compliance CFP #6 — Tipos de consent form (LGPD + Gravação)
-- ----------------------------------------------------------------------------
-- Estende o CHECK constraint de document_templates.tipo para acomodar dois
-- novos tipos de consent form exigidos pela LGPD e pela prática clínica:
-- • termo_lgpd — Consentimento de tratamento de dados pessoais
-- • autorizacao_gravacao — Autorização de gravação de sessão (áudio/vídeo)
--
-- ROADMAP item #1.2 #6 (Biblioteca de consent forms editáveis).
-- ============================================================================
BEGIN;
ALTER TABLE public.document_templates
DROP CONSTRAINT IF EXISTS dt_tipo_check;
ALTER TABLE public.document_templates
ADD CONSTRAINT dt_tipo_check CHECK (
tipo = ANY (ARRAY[
'declaracao_comparecimento',
'atestado_psicologico',
'relatorio_acompanhamento',
'recibo_pagamento',
'termo_consentimento',
'encaminhamento',
'contrato_servicos',
'tcle',
'autorizacao_menor',
'laudo_psicologico',
'parecer_psicologico',
'termo_sigilo',
'declaracao_inicio_tratamento',
'termo_alta',
'tcle_online',
'termo_lgpd',
'autorizacao_gravacao',
'outro'
])
);
COMMENT ON COLUMN public.document_templates.tipo IS
'Tipo do template. Inclui consent forms (tcle, tcle_online, autorizacao_menor, termo_sigilo, termo_lgpd, autorizacao_gravacao).';
COMMIT;
@@ -0,0 +1,251 @@
-- ============================================================================
-- Compliance CFP #7 — RPCs de assinatura eletrônica
-- ----------------------------------------------------------------------------
-- Cria 2 RPCs que registram assinatura capturando IP server-side (anti-spoof)
-- via inet_client_addr() e user-agent via request headers do Supabase.
--
-- • sign_document_by_signature_id — paciente logado assina via portal
-- • sign_document_by_token — terceiro assina via share link público
--
-- ROADMAP item #1.2 #7 (Assinatura eletrônica pelo paciente no portal,
-- simples, com IP+timestamp). Não usa ICP-Brasil — é assinatura simples
-- com audit trail (IP, UA, timestamp, hash SHA-256 do documento).
-- ============================================================================
BEGIN;
-- ──────────────────────────────────────────────────────────────────────────
-- 1. sign_document_by_signature_id
-- ----------------------------------------------------------------------------
-- Para signatários LOGADOS no portal/sistema. SECURITY INVOKER — a RLS de
-- document_signatures continua aplicando (signatario_id = auth.uid() ou
-- tenant_members). RPC só serve pra centralizar captura de IP + UA + hash.
-- ──────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.sign_document_by_signature_id(
p_signature_id uuid,
p_hash_documento text DEFAULT NULL
) RETURNS public.document_signatures
LANGUAGE plpgsql
SECURITY INVOKER
AS $$
DECLARE
v_row public.document_signatures;
v_ip inet;
v_ua text;
BEGIN
IF p_signature_id IS NULL THEN
RAISE EXCEPTION 'p_signature_id obrigatório' USING ERRCODE = '22023';
END IF;
-- Captura IP e UA do request (best-effort — pode vir NULL em alguns ambientes)
v_ip := inet_client_addr();
BEGIN
v_ua := current_setting('request.headers', true)::json ->> 'user-agent';
EXCEPTION WHEN OTHERS THEN
v_ua := NULL;
END;
UPDATE public.document_signatures
SET status = 'assinado',
ip = v_ip,
user_agent = v_ua,
assinado_em = now(),
hash_documento = COALESCE(p_hash_documento, hash_documento),
atualizado_em = now()
WHERE id = p_signature_id
AND status IN ('pendente', 'enviado')
RETURNING * INTO v_row;
IF v_row.id IS NULL THEN
RAISE EXCEPTION 'Assinatura não encontrada ou já processada' USING ERRCODE = 'P0002';
END IF;
RETURN v_row;
END;
$$;
COMMENT ON FUNCTION public.sign_document_by_signature_id(uuid, text) IS
'Assinatura via portal logado. Captura IP/UA server-side. RLS aplica (SECURITY INVOKER).';
GRANT EXECUTE ON FUNCTION public.sign_document_by_signature_id(uuid, text) TO authenticated;
-- ──────────────────────────────────────────────────────────────────────────
-- 2. sign_document_by_token
-- ----------------------------------------------------------------------------
-- Para signatários NÃO LOGADOS via share link público. SECURITY DEFINER —
-- bypassa RLS. Valida o share_link (token, ativo, expira_em, usos_max),
-- localiza o signatário PENDENTE associado ao documento (signatario_email
-- opcional p/ desambiguar quando há múltiplos), assina, incrementa usos.
-- ──────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.sign_document_by_token(
p_token text,
p_signature_id uuid DEFAULT NULL,
p_hash_documento text DEFAULT NULL
) RETURNS public.document_signatures
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_link public.document_share_links;
v_sig public.document_signatures;
v_ip inet;
v_ua text;
BEGIN
IF p_token IS NULL OR length(p_token) < 32 THEN
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '22023';
END IF;
-- Valida share_link
SELECT * INTO v_link
FROM public.document_share_links
WHERE token = p_token
AND ativo = true
AND expira_em > now()
AND usos < usos_max
LIMIT 1;
IF v_link.id IS NULL THEN
RAISE EXCEPTION 'Link expirado, inválido ou esgotado' USING ERRCODE = 'P0002';
END IF;
-- Localiza a signature pendente do documento. Se p_signature_id veio,
-- é desambiguação (multi-signatário); senão pega a primeira pendente
-- por ordem.
IF p_signature_id IS NOT NULL THEN
SELECT * INTO v_sig
FROM public.document_signatures
WHERE id = p_signature_id
AND documento_id = v_link.documento_id
AND status IN ('pendente', 'enviado')
LIMIT 1;
ELSE
SELECT * INTO v_sig
FROM public.document_signatures
WHERE documento_id = v_link.documento_id
AND status IN ('pendente', 'enviado')
ORDER BY ordem ASC, criado_em ASC
LIMIT 1;
END IF;
IF v_sig.id IS NULL THEN
RAISE EXCEPTION 'Nenhuma assinatura pendente para este documento' USING ERRCODE = 'P0002';
END IF;
-- Captura IP/UA
v_ip := inet_client_addr();
BEGIN
v_ua := current_setting('request.headers', true)::json ->> 'user-agent';
EXCEPTION WHEN OTHERS THEN
v_ua := NULL;
END;
-- Assina
UPDATE public.document_signatures
SET status = 'assinado',
ip = v_ip,
user_agent = v_ua,
assinado_em = now(),
hash_documento = COALESCE(p_hash_documento, hash_documento),
atualizado_em = now()
WHERE id = v_sig.id
RETURNING * INTO v_sig;
-- Incrementa contador de usos do share_link
UPDATE public.document_share_links
SET usos = usos + 1
WHERE id = v_link.id;
RETURN v_sig;
END;
$$;
COMMENT ON FUNCTION public.sign_document_by_token(text, uuid, text) IS
'Assinatura via share link público. SECURITY DEFINER — valida token, captura IP/UA, incrementa usos. p_signature_id é opcional pra desambiguar multi-signatário.';
GRANT EXECUTE ON FUNCTION public.sign_document_by_token(text, uuid, text) TO anon, authenticated;
-- ──────────────────────────────────────────────────────────────────────────
-- 3. get_signable_document_by_token
-- ----------------------------------------------------------------------------
-- View helper que retorna info do documento + signatários pendentes via token,
-- sem assinar. Permite a página pública renderizar antes do click.
-- SECURITY DEFINER porque share_link tem RLS pública mas documents+signatures
-- têm RLS por owner/tenant.
-- ──────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.get_signable_document_by_token(
p_token text
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_link public.document_share_links;
v_doc public.documents;
v_sigs jsonb;
BEGIN
IF p_token IS NULL OR length(p_token) < 32 THEN
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '22023';
END IF;
SELECT * INTO v_link
FROM public.document_share_links
WHERE token = p_token
AND ativo = true
AND expira_em > now()
AND usos < usos_max
LIMIT 1;
IF v_link.id IS NULL THEN
RETURN jsonb_build_object('valid', false, 'error', 'expired_or_invalid');
END IF;
SELECT * INTO v_doc
FROM public.documents
WHERE id = v_link.documento_id
AND deleted_at IS NULL
LIMIT 1;
IF v_doc.id IS NULL THEN
RETURN jsonb_build_object('valid', false, 'error', 'document_not_found');
END IF;
SELECT jsonb_agg(
jsonb_build_object(
'id', s.id,
'signatario_tipo', s.signatario_tipo,
'signatario_nome', s.signatario_nome,
'signatario_email', s.signatario_email,
'ordem', s.ordem,
'status', s.status,
'assinado_em', s.assinado_em
) ORDER BY s.ordem
) INTO v_sigs
FROM public.document_signatures s
WHERE s.documento_id = v_doc.id;
RETURN jsonb_build_object(
'valid', true,
'document', jsonb_build_object(
'id', v_doc.id,
'nome_original', v_doc.nome_original,
'mime_type', v_doc.mime_type,
'tamanho_bytes', v_doc.tamanho_bytes,
'bucket_path', v_doc.bucket_path,
'storage_bucket', v_doc.storage_bucket,
'tipo_documento', v_doc.tipo_documento
),
'signatures', COALESCE(v_sigs, '[]'::jsonb),
'expira_em', v_link.expira_em,
'usos_restantes', v_link.usos_max - v_link.usos
);
END;
$$;
COMMENT ON FUNCTION public.get_signable_document_by_token(text) IS
'Retorna documento + signatários pendentes via token. Usado pela página pública antes de assinar.';
GRANT EXECUTE ON FUNCTION public.get_signable_document_by_token(text) TO anon, authenticated;
COMMIT;
@@ -0,0 +1,102 @@
-- ============================================================================
-- Compliance CFP #7 — RPC list_my_signatures (portal do paciente)
-- ----------------------------------------------------------------------------
-- Retorna as solicitações de assinatura do paciente logado (auth.uid()
-- associado a patients.user_id). SECURITY DEFINER pra bypassar a RLS de
-- document_signatures (que hoje só libera pra tenant_members).
--
-- Cada item já vem com o share_link.token associado, pra que o portal
-- aponte direto pra /shared/document/:token onde o usuário vai assinar.
-- O link público é gerado quando o terapeuta solicita a assinatura.
-- ============================================================================
BEGIN;
CREATE OR REPLACE FUNCTION public.list_my_signatures(
p_status text[] DEFAULT NULL
) RETURNS TABLE (
signature_id uuid,
documento_id uuid,
tenant_id uuid,
signatario_tipo text,
status text,
ordem smallint,
assinado_em timestamptz,
criado_em timestamptz,
-- Documento
nome_original text,
tipo_documento text,
mime_type text,
-- Share link (primeiro válido encontrado pro doc)
share_token text,
share_expira_em timestamptz
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_uid uuid;
BEGIN
v_uid := auth.uid();
IF v_uid IS NULL THEN
RAISE EXCEPTION 'Sessão inválida' USING ERRCODE = '28000';
END IF;
RETURN QUERY
SELECT
s.id AS signature_id,
s.documento_id AS documento_id,
s.tenant_id AS tenant_id,
s.signatario_tipo AS signatario_tipo,
s.status AS status,
s.ordem AS ordem,
s.assinado_em AS assinado_em,
s.criado_em AS criado_em,
d.nome_original AS nome_original,
d.tipo_documento AS tipo_documento,
d.mime_type AS mime_type,
sl.token AS share_token,
sl.expira_em AS share_expira_em
FROM public.document_signatures s
JOIN public.documents d ON d.id = s.documento_id AND d.deleted_at IS NULL
LEFT JOIN LATERAL (
SELECT token, expira_em
FROM public.document_share_links
WHERE documento_id = d.id
AND ativo = true
AND expira_em > now()
AND usos < usos_max
ORDER BY criado_em DESC
LIMIT 1
) sl ON true
WHERE (
-- signatario_id direto (quando registrado)
s.signatario_id = v_uid
OR
-- Fallback: paciente pelo email (quando signatario_id veio NULL)
s.signatario_email = (SELECT email FROM auth.users WHERE id = v_uid)
OR
-- Fallback: paciente pelo patient_id (documents.patient_id -> patients.user_id)
d.patient_id IN (SELECT p.id FROM public.patients p WHERE p.user_id = v_uid)
)
AND (p_status IS NULL OR s.status = ANY (p_status))
ORDER BY
CASE s.status
WHEN 'pendente' THEN 0
WHEN 'enviado' THEN 1
WHEN 'assinado' THEN 2
WHEN 'recusado' THEN 3
WHEN 'expirado' THEN 4
ELSE 99
END,
s.criado_em DESC;
END;
$$;
COMMENT ON FUNCTION public.list_my_signatures(text[]) IS
'Lista signatures do paciente logado (auth.uid()) cruzando por signatario_id, email ou patient.user_id. Inclui share_token pra link de assinatura.';
GRANT EXECUTE ON FUNCTION public.list_my_signatures(text[]) TO authenticated;
COMMIT;
@@ -0,0 +1,35 @@
-- ============================================================================
-- ROADMAP #1.4 #14 — Recibo profissional usa terapeuta_registro genérico
-- ----------------------------------------------------------------------------
-- O template recibo_pagamento (seed_015) usa "Psicólogo(a) — CRP {{terapeuta_crp}}".
-- Como agora suportamos múltiplos conselhos (CRP/CRM/CRFa/CREFITO/CRESS/CRN/RMS)
-- via #5 (migration 20260521000003), o recibo precisa ser CFP-agnóstico.
--
-- Esta migration substitui no recibo_pagamento:
-- "Psicólogo(a) — CRP {{terapeuta_crp}}" → "{{terapeuta_registro}}"
-- e atualiza variaveis[] removendo terapeuta_crp + adicionando terapeuta_registro.
--
-- {{terapeuta_registro}} é auto-formatado server-side como "CRP 12345/SP",
-- "CRM 12345/SP" etc, então não precisa de "Psicólogo(a) —" hardcoded.
-- ============================================================================
BEGIN;
UPDATE public.document_templates
SET corpo_html = REPLACE(
corpo_html,
'Psicólogo(a) — CRP {{terapeuta_crp}}',
'{{terapeuta_registro}}'
),
variaveis = ARRAY(
SELECT DISTINCT v FROM (
SELECT unnest(variaveis) v
UNION ALL
SELECT 'terapeuta_registro'
) sub
WHERE v <> 'terapeuta_crp'
),
updated_at = now()
WHERE tipo = 'recibo_pagamento' AND is_global = true;
COMMIT;
@@ -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,74 @@
-- ============================================================================
-- Compliance CFP #6 — Consent forms extra (LGPD + Gravação) + LGPD amend
-- ----------------------------------------------------------------------------
-- Adiciona 2 templates globais novos exigidos pra completar a biblioteca de
-- consent forms do ROADMAP item #1.2 #6:
-- • termo_lgpd — Consentimento LGPD (tratamento de dados pessoais)
-- • autorizacao_gravacao — Autorização de gravação de sessão
--
-- Também atualiza o template tcle_online existente pra incluir cláusula
-- explícita de LGPD (estava mencionando criptografia mas não a Lei 13.709/2018
-- nem direitos do titular).
--
-- Pré-requisito: migration 20260521000005_document_templates_consent_types.sql
-- já aplicada (adiciona 'termo_lgpd' e 'autorizacao_gravacao' ao CHECK).
-- ============================================================================
BEGIN;
-- ──────────────────────────────────────────────────────────────────────────
-- 1. Termo de Consentimento LGPD (tratamento de dados pessoais)
-- ──────────────────────────────────────────────────────────────────────────
INSERT INTO public.document_templates (
id, tenant_id, owner_id, nome_template, tipo, descricao,
corpo_html, cabecalho_html, rodape_html,
variaveis, is_global, ativo
) VALUES (
gen_random_uuid(), NULL, NULL,
'Termo de Consentimento LGPD',
'termo_lgpd',
'Consentimento específico para tratamento de dados pessoais conforme Lei nº 13.709/2018 (LGPD).',
E'<h2 style="text-align:center; margin-bottom:30px;">TERMO DE CONSENTIMENTO PARA TRATAMENTO DE DADOS PESSOAIS</h2>\n\n<p>Em conformidade com a <strong>Lei Geral de Proteção de Dados Pessoais — Lei nº 13.709/2018 (LGPD)</strong>, eu, <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, declaro ter sido informado(a) e <strong>consinto livremente</strong> com o tratamento dos meus dados pessoais nos termos abaixo.</p>\n\n<h3>1. Controlador dos dados</h3>\n<p><strong>{{terapeuta_nome}}</strong>, Psicólogo(a) — CRP <strong>{{terapeuta_crp}}</strong>, atuando em <strong>{{clinica_nome}}</strong>, com endereço em <strong>{{clinica_endereco}}</strong>, atua como controlador dos dados pessoais coletados.</p>\n\n<h3>2. Dados tratados</h3>\n<p>Serão coletados e tratados os seguintes dados:</p>\n<ul>\n <li><strong>Identificação:</strong> nome, CPF, RG, data de nascimento, endereço, telefone, e-mail;</li>\n <li><strong>Dados sensíveis de saúde:</strong> histórico clínico, hipóteses diagnósticas, evolução terapêutica, prescrições, encaminhamentos (Art. 11 LGPD);</li>\n <li><strong>Dados de pagamento:</strong> valores, formas de pagamento, recibos emitidos;</li>\n <li><strong>Registros de atendimento:</strong> data, horário, modalidade e duração das sessões.</li>\n</ul>\n\n<h3>3. Finalidade e base legal</h3>\n<p>Os dados serão utilizados exclusivamente para:</p>\n<ul>\n <li><strong>Execução do contrato</strong> de prestação de serviços psicológicos (Art. 7º, V e Art. 11, II, "a" da LGPD);</li>\n <li>Cumprimento de <strong>obrigações legais e regulatórias</strong> (Resoluções CFP, retenção de prontuário por 5 anos — Art. 1º Res. CFP 001/2009);</li>\n <li>Proteção da <strong>vida e incolumidade física</strong> do titular ou de terceiros, quando necessário (Art. 11, II, "f" LGPD);</li>\n <li>Emissão de documentos solicitados (recibos, atestados, declarações).</li>\n</ul>\n\n<h3>4. Compartilhamento</h3>\n<p>Os dados <strong>não serão compartilhados</strong> com terceiros, exceto:</p>\n<ul>\n <li>Mediante <strong>autorização expressa</strong> do titular (ex: encaminhamentos);</li>\n <li>Por <strong>determinação judicial</strong> ou requisição legal de autoridade competente;</li>\n <li>Para <strong>processadores</strong> contratados (plataforma de prontuário eletrônico, serviços de armazenamento em nuvem), com cláusulas de confidencialidade e proteção equivalentes.</li>\n</ul>\n\n<h3>5. Armazenamento e retenção</h3>\n<p>Os dados serão mantidos pelo prazo mínimo de <strong>5 anos</strong> após o término do acompanhamento, conforme exigência do CFP (Resolução nº 001/2009), em ambiente eletrônico criptografado com controle de acesso restrito ao profissional responsável.</p>\n\n<h3>6. Direitos do titular (Art. 18 LGPD)</h3>\n<p>O(A) titular pode, a qualquer momento, solicitar ao controlador:</p>\n<ul>\n <li>Confirmação da existência de tratamento;</li>\n <li>Acesso aos seus dados;</li>\n <li>Correção de dados incompletos, inexatos ou desatualizados;</li>\n <li>Anonimização, bloqueio ou eliminação de dados desnecessários ou tratados em desconformidade;</li>\n <li>Portabilidade dos dados;</li>\n <li>Revogação deste consentimento, nos termos do §5º do Art. 8º (sem prejuízo do tratamento legalmente exigido).</li>\n</ul>\n\n<h3>7. Contato</h3>\n<p>Para exercer seus direitos ou esclarecer dúvidas: <strong>{{terapeuta_email}}</strong> · <strong>{{terapeuta_telefone}}</strong>.</p>\n\n<p style="margin-top:30px;">Declaro que <strong>li, compreendi e consinto</strong> livremente com o tratamento dos meus dados pessoais conforme descrito acima.</p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{paciente_nome}}</strong><br/>\n Titular dos dados — CPF: {{paciente_cpf}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Controlador — CRP {{terapeuta_crp}}\n </div>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Documento regido pela Lei nº 13.709/2018 (LGPD) e pelo Código de Ética Profissional do Psicólogo.\n</div>',
ARRAY['paciente_nome','paciente_cpf','terapeuta_nome','terapeuta_crp','terapeuta_email','terapeuta_telefone','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
true, true
);
-- ──────────────────────────────────────────────────────────────────────────
-- 2. Autorização para Gravação de Sessão
-- ──────────────────────────────────────────────────────────────────────────
INSERT INTO public.document_templates (
id, tenant_id, owner_id, nome_template, tipo, descricao,
corpo_html, cabecalho_html, rodape_html,
variaveis, is_global, ativo
) VALUES (
gen_random_uuid(), NULL, NULL,
'Autorização para Gravação de Sessão',
'autorizacao_gravacao',
'Autorização específica do paciente para gravação de áudio/vídeo das sessões (supervisão, ensino, registro clínico).',
E'<h2 style="text-align:center; margin-bottom:30px;">AUTORIZAÇÃO PARA GRAVAÇÃO DE SESSÃO</h2>\n\n<p>Eu, <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, declaro ter sido devidamente informado(a) pelo(a) psicólogo(a) <strong>{{terapeuta_nome}}</strong>, CRP <strong>{{terapeuta_crp}}</strong>, e <strong>AUTORIZO</strong> a gravação das sessões de atendimento psicológico nas condições abaixo.</p>\n\n<h3>1. Tipo de gravação</h3>\n<p>Modalidade autorizada: <strong>{{tipo_gravacao}}</strong> (áudio, vídeo ou ambos).</p>\n\n<h3>2. Finalidade</h3>\n<p>As gravações serão utilizadas exclusivamente para:</p>\n<ul>\n <li><strong>{{finalidade_gravacao}}</strong></li>\n</ul>\n<p>Finalidades comuns: registro clínico para análise posterior do profissional; supervisão técnica com supervisor identificado; uso didático em formação (com anonimização); pesquisa científica (mediante consentimento adicional específico).</p>\n\n<h3>3. Compartilhamento</h3>\n<p>As gravações são <strong>confidenciais</strong>. Não serão compartilhadas com terceiros, exceto quando:</p>\n<ul>\n <li>Houver autorização expressa e por escrito do(a) titular;</li>\n <li>Para fins de supervisão técnica, com o(a) supervisor(a) identificado(a) — <strong>{{supervisor_nome}}</strong> (quando aplicável);</li>\n <li>Anonimizadas, para fins didáticos ou de pesquisa (com novo consentimento específico).</li>\n</ul>\n\n<h3>4. Armazenamento e descarte</h3>\n<p>As gravações serão armazenadas em ambiente criptografado, com acesso restrito ao(à) profissional responsável, pelo prazo de <strong>{{prazo_retencao}}</strong>, após o qual serão definitivamente eliminadas, conforme a LGPD (Lei nº 13.709/2018).</p>\n\n<h3>5. Direitos do(a) paciente</h3>\n<ul>\n <li>Revogar esta autorização a qualquer tempo, com efeito sobre gravações futuras;</li>\n <li>Solicitar a eliminação imediata de gravações específicas;</li>\n <li>Solicitar cópia da gravação para fins próprios;</li>\n <li>Ser informado(a) sobre cada utilização (supervisão, pesquisa).</li>\n</ul>\n\n<h3>6. Considerações éticas</h3>\n<p>A presente autorização está em conformidade com o Código de Ética Profissional do Psicólogo, com a Resolução CFP nº 010/2005 (sigilo profissional) e com a Lei nº 13.709/2018 (LGPD). A negativa de gravação <strong>não prejudica</strong> o atendimento psicológico, que prosseguirá normalmente.</p>\n\n<p style="margin-top:30px;">Declaro que <strong>li, compreendi e autorizo</strong> a gravação das sessões nos termos acima.</p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{paciente_nome}}</strong><br/>\n CPF: {{paciente_cpf}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n </div>\n</div>',
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Autorização regida pelo Código de Ética do Psicólogo (CFP 010/2005) e pela Lei nº 13.709/2018 (LGPD).\n</div>',
ARRAY['paciente_nome','paciente_cpf','terapeuta_nome','terapeuta_crp','tipo_gravacao','finalidade_gravacao','supervisor_nome','prazo_retencao','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
true, true
);
-- ──────────────────────────────────────────────────────────────────────────
-- 3. Amend tcle_online com cláusula LGPD explícita
-- ----------------------------------------------------------------------------
-- O template original (seed_015) menciona criptografia mas não cita a LGPD
-- explicitamente nem os direitos do titular. Acrescenta uma seção 5.
-- ──────────────────────────────────────────────────────────────────────────
UPDATE public.document_templates
SET corpo_html = REPLACE(
corpo_html,
'<h3>4. Limitações</h3>',
E'<h3>5. Proteção de Dados (LGPD)</h3>\n<p>O atendimento online é regido pela <strong>Lei Geral de Proteção de Dados — Lei nº 13.709/2018 (LGPD)</strong>. Você tem direito a (Art. 18 LGPD): confirmar a existência de tratamento dos seus dados; acessar seus dados; corrigir dados incompletos ou inexatos; solicitar eliminação dos dados após o término do tratamento (resguardados os prazos legais de retenção do CFP); e revogar este consentimento a qualquer momento. Para exercê-los, contate <strong>{{terapeuta_email}}</strong>.</p>\n\n<h3>6. Limitações</h3>'
),
variaveis = ARRAY['paciente_nome','paciente_cpf','plataforma_online','terapeuta_nome','terapeuta_crp','terapeuta_email','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
descricao = 'Consentimento específico para atendimento psicológico por meios tecnológicos (Resolução CFP nº 11/2018) + cláusula LGPD.',
updated_at = now()
WHERE tipo = 'tcle_online' AND is_global = true;
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;
+25 -9
View File
@@ -107,22 +107,38 @@ Do `project_graphify_findings_20260504`:
### Fase 3 — Gaps de MVP (Fase 1 do ROADMAP)
- [🟡] **Gateway Asaas (Fase A foundation 2026-05-21)** — Design doc + 2 migrations (tables + RLS) + client service + 3 Edge Function stubs (create-payment-record, cancel-payment, sync-payment). Schema: `asaas_customers`, `asaas_payments`, `asaas_webhook_events` + 5 colunas em `payment_settings`. Fase B (implementação real) depende de credenciais + decisão modelo negócio (A/B/C). Ver `development/02-auditoria/DESIGN_ASAAS_GATEWAY.md`.
- [🟡] **Compliance CFP (#5/#8/#9 done · #6/#7 deferred · 2026-05-21)**
- [x] **Compliance CFP (#5/#6/#7/#8/#9 todos done · 2026-05-21)**
- #5 (registro profissional): migration `20260521000003_profiles_professional_registration.sql` — adiciona `professional_registration_type` (CHECK 8 conselhos) + `_number` + `_uf`.
- #6 (consent forms editáveis): migration `20260521000005_document_templates_consent_types.sql` estende CHECK com `termo_lgpd` + `autorizacao_gravacao`. `seed_060_consent_forms_extra.sql` insere 2 templates novos (LGPD + Gravação) + UPDATE no `tcle_online` adicionando cláusula LGPD. Biblioteca completa: TCLE base, tcle_online (telehealth), autorizacao_menor, termo_sigilo, termo_lgpd, autorizacao_gravacao + UI já existente (`DocumentTemplatesPage` + `DocumentTemplateEditor`).
- #7 (assinatura eletrônica no portal): 2 migrations RPC — `20260521000006` cria `sign_document_by_signature_id` + `sign_document_by_token` + `get_signable_document_by_token` (IP/UA capturados server-side via `inet_client_addr()` + `current_setting('request.headers')`); `20260521000007` cria `list_my_signatures` (cruzamento auth.uid() por 3 caminhos). `DocumentSignatures.service` estendido. `useDocumentSignatures` composable novo. `PortalDocumentos.vue` lista pendências do paciente logado. `SharedDocumentPage.vue` estendida com painel azul de assinatura (aviso LGPD + checkbox aceite + Assinar/Recusar). `DocumentSignatureDialog` (terapeuta-side, já existia) ganha checkbox "Gerar link público" + select de validade + bloco com URL gerado/copy.
- #8 (nome social): JÁ INTEGRADO — `patients.nome_social` schema existia + UI em 7 arquivos.
- #9 (especialidades): `20260521000004_specialties.sql` (tabela + profile_specialties M:N + RLS) + `seed_050_specialties.sql` (33 specialties) + `src/services/specialtiesService.js`.
- **#6 consent forms DEFERRED**: schema `document_templates` existe; falta seed + UI editor + workflow.
- **#7 assinatura DEFERRED**: schema `document_signatures` existe com status flow completo; falta portal UI pra paciente.
- [x] **Recibo profissional PDF (#14 · 2026-05-21)**`valorExtenso.js` helper pt-BR. `DocumentGenerate.service` puxa registro profissional do profile (auto-formato `CRP 12345/SP`), formata `cpf_cnpj` do tenant, computa `valor`+`valor_extenso`, mapeia `templateTipo``tipo_documento` (recibo_pagamento → 'recibo'). Migration `20260521000008` substitui `{{terapeuta_crp}}` por `{{terapeuta_registro}}` no template — universal pra qualquer conselho. `emitirReciboParaSessao(eventoId, opts)` é quick path one-call. Botão "Emitir recibo" no `AgendaEventoFinanceiroPanel` quando `record.status === 'paid'`. #15 NFS-e ainda em aberto.
- [x] **§1.3 UX block 3/4 (#10 + #11 + #13 · 2026-05-21)** —
- #10 Busca global: `GlobalSearch.vue` (RPC `search_global`) já estava completo no AppTopbar/Rail. **MelissaBusca promovida** de client-side preview pra RPC com debounce 200ms + searchSeq. 3 grupos novos (rpc-appointments, rpc-documents, rpc-intakes). `@paciente` no MelissaLayout corrigido pra navegar pro paciente clicado (era bug — ignorava payload).
- #11 Recently viewed: `composables/useRecentPatients.js` (localStorage por user_id, max 5, dedup, eventos sync entre instâncias). `registerPatientVisit` chamado em `MelissaPaciente.loadAll` + `PatientProntuario.loadDetail`. Grupo "Acessados recentemente" no GlobalSearch + MelissaBusca quando query vazia.
- #13 Relatórios export: `services/reportExport.service.js` com 3 funções (PDF via pdf.service, Excel via exceljs com import dinâmico, CSV vanilla). 3 botões no header de `RelatoriosPage.vue` e `MelissaRelatorios.vue`.
- [ ] **#12 Papel timbrado (BLOQUEADO)** — código no UniaoApp (projeto externo). Quando user importar o código, plugar como variável `cabecalho_html`/`rodape_html` global em `document_templates` ou criar setting `tenants.letterhead_html`.
- [ ] NFS-e emissão (#15) — Esforço L, decisão de provider pendente (Focus NF-e vs prefeitura direta).
- [ ] E2E Playwright crítico (#16)
- [ ] Sentry (#18)
### Fase 4 — Agenda residual (por último)
### Fase 4 — Agenda residual
- [ ] Popover snapshot stale`ev.id` + computed
- [ ] Reverse transition confirm dialogs (realizado paid, faltou multa, pacote saldo)
- [ ] Replicação Rail (AgendaTerapeutaPage) + Clínica (AgendaClinicaPage)
- [ ] C12 antecipar — iterar UX
- [ ] Doc de ajuda completa
- [x] **Popover snapshot stale** (commit `f83315b` durante C11) — watch em `MelissaLayout` cobre virtual→materializada.
- [x] **Reverse transition confirm dialogs** (commit `5684297` durante C11) — `ctx.reverseArtifacts` + dialog.
- [x] **Decomposição agenda (Fases A+B1+B2 · 2026-05-21)**`useMelissaAgenda.js` saiu de 3033L → 2042L (-991L, ~33%). 3 utils + 1 service novo (`agendaBilling.service`).
- Fase A: `features/agenda/utils/{eventoTipo,dbFields,timeHelpers,colors}.js`
- Fase B1 (commit `e7e3d1b`): service ganha `computeSeriePrice`, `generateOccurrenceDates`, `loadStatusChangeContext`, `needsStatusConfirmDialog`.
- Fase B2 (commit `049dd91`): service ganha `applyStatusDecisions`, `createPackageContract`, `materializeAndChargePerSession`.
- [x] **Replicação Rail + Clínica (Fases C+D · 2026-05-21)**
- Composable novo `useAgendaStatusChange` (Tipo A wrapper) reusável em qualquer page.
- Fase C (commit `034c2c0`): `AgendaTerapeutaPage.onUpdateSeriesEvent` refatorado + `AgendaStatusChangeConfirmDialog` plugado. Antes era `update(id, {status})` cru; agora cobre multa + pacote saldo + reverse.
- Fase D (commit `6807b44`): `AgendaClinicaPage` espelha Fase C com adaptações (`updateClinic`+`createClinic` recebem `tenantId` arg).
- [x] **C12 antecipar UX iter** (commits `9c518a2` + `b7f3c23`) — "Trocar método" pattern (UPDATE em vez de cancel cycle) + filtro cancelled no dialog Lançamentos.
- [ ] **Indicadores visuais 3 canais** (barra esquerda verde / badge $ amber / neutro) — replicar no Rail/Clínica. Custom event classNames do FullCalendar, requer `_paymentStateMap` bulk-load igual ao Melissa.
- [ ] **Popover Rail antecipar/revogar/trocar método** — Rail não tem popover separado (usa AgendaEventDialog direto), precisa refactor maior pra acomodar.
- [ ] **Doc de ajuda completa** — user enviará prompt específico.
---
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>
@@ -36,6 +36,7 @@ import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
import { useAgendaFinanceiro } from '@/composables/useAgendaFinanceiro';
import { emitirReciboParaSessao } from '@/services/DocumentGenerate.service';
// props / emits
const props = defineProps({
@@ -56,6 +57,7 @@ const { gerarCobrancaManual, loading: finLoading, error: finError } = useAgendaF
const record = ref(null); // financial_record vinculado
const fetching = ref(false);
const generating = ref(false);
const emittingRecibo = ref(false);
// opções de método de pagamento
const PAYMENT_METHODS = [
@@ -224,6 +226,27 @@ function requestCancel() {
}
});
}
// Emitir recibo PDF da sessão
// Gera, salva (Storage + documents/document_generated) e baixa um recibo
// pré-preenchido com paciente/sessão/valor/forma de pagamento + registro
// profissional do terapeuta (CRP/CRM/CRFa etc auto-formatado).
async function onEmitirRecibo() {
if (emittingRecibo.value) return;
emittingRecibo.value = true;
try {
await emitirReciboParaSessao(props.evento.id, {
patientId: props.evento.patient_id || props.evento.paciente_id,
valor: record.value?.final_amount ?? record.value?.amount ?? props.evento.price,
formaPagamento: paymentLabel(record.value?.payment_method)
});
toast.add({ severity: 'success', summary: 'Recibo emitido', detail: 'PDF baixado e salvo nos documentos do paciente.', life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao emitir recibo', detail: e?.message || 'Tente novamente.', life: 4500 });
} finally {
emittingRecibo.value = false;
}
}
</script>
<template>
@@ -292,6 +315,20 @@ function requestCancel() {
<Button label="Receber" icon="pi pi-check" size="small" class="rounded-full flex-1" @click="openPayDialog" />
<Button icon="pi pi-times" size="small" severity="danger" outlined class="rounded-full h-7 w-7" v-tooltip.top="'Cancelar cobrança'" @click="requestCancel" />
</div>
<!-- Ação: pago emitir recibo PDF -->
<div v-else-if="record.status === 'paid'" class="flex gap-1.5 mt-3">
<Button
label="Emitir recibo"
icon="pi pi-file-pdf"
size="small"
outlined
class="rounded-full flex-1"
:loading="emittingRecibo"
v-tooltip.top="'Gera PDF e salva nos documentos do paciente'"
@click="onEmitirRecibo"
/>
</div>
</div>
</div>
+38
View File
@@ -15,9 +15,11 @@ import InputText from 'primevue/inputtext';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { searchPages } from './pagesIndex';
import { useRecentPatients } from '@/composables/useRecentPatients';
const router = useRouter();
const tenantStore = useTenantStore();
const { items: recentPatients, hasItems: hasRecentPatients } = useRecentPatients();
//
// State
@@ -67,9 +69,14 @@ const filteredPages = computed(() => {
//
// Flat list pra navegação por teclado
//
// Recently-viewed só aparece quando a query está vazia não polui resultados de busca.
const showRecent = computed(() => !query.value.trim() && hasRecentPatients.value);
const recentItems = computed(() => showRecent.value ? (recentPatients.value || []).slice(0, 5) : []);
const flatList = computed(() => {
const out = [];
filteredActions.value.forEach((a, i) => out.push({ group: 'actions', item: a, idx: i }));
recentItems.value.forEach((p, i) => out.push({ group: 'recent', item: p, idx: i }));
results.value.patients.forEach((p, i) => out.push({ group: 'patients', item: p, idx: i }));
results.value.intakes.forEach((r, i) => out.push({ group: 'intakes', item: r, idx: i }));
results.value.appointments.forEach((a, i) => out.push({ group: 'appointments', item: a, idx: i }));
@@ -195,6 +202,16 @@ function onInputKeydown(e) {
}
async function goTo(entry) {
// Recent patients: usa id pra navegar pro prontuário do paciente
if (entry?.group === 'recent' && entry?.item?.id) {
showPanel.value = false;
query.value = '';
resetResults();
activeIndex.value = -1;
await router.push({ path: '/therapist/patients/' + entry.item.id });
return;
}
const target = entry?.item?.to || entry?.item?.deeplink || entry?.item?.path;
if (!target) return;
showPanel.value = false;
@@ -266,6 +283,27 @@ const kbdModifier = kbdIsMac ? '⌘' : 'Ctrl';
</div>
<template v-else>
<!-- Acessados recentemente ( quando query vazia) -->
<div v-if="showRecent" class="gs-group">
<div class="gs-group__title">Acessados recentemente</div>
<button
v-for="(p, i) in recentItems"
:key="'rp-' + p.id"
type="button"
class="gs-item"
:class="{ 'is-active': findFlatIndex('recent', i) === activeIndex }"
@mouseenter="activeIndex = findFlatIndex('recent', i)"
@click="goTo({ group: 'recent', item: p, idx: i })"
>
<span class="gs-item__icon"><i class="pi pi-history" /></span>
<span class="gs-item__main">
<span class="gs-item__label">{{ p.nome }}</span>
<span class="gs-item__sub">{{ p.extras?.telefone || p.extras?.email || 'Abrir prontuário' }}</span>
</span>
<i class="gs-item__go pi pi-arrow-right" />
</button>
</div>
<!-- Ações -->
<div v-if="filteredActions.length" class="gs-group">
<div class="gs-group__title">{{ query.trim() ? 'Ações' : 'Atalhos' }}</div>
+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>
+171
View File
@@ -0,0 +1,171 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/composables/useRecentPatients.js
|
| Tracking dos últimos pacientes acessados pelo usuário logado.
| Armazenado em localStorage por user_id pra isolar sessões diferentes
| no mesmo browser (multi-conta).
|
| Usado pelo GlobalSearch.vue / MelissaBusca.vue como "recently viewed"
| quando o input está vazio, e pode ser embedido em dashboards.
|--------------------------------------------------------------------------
*/
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { supabase } from '@/lib/supabase/client';
const MAX_ITEMS = 5; // top N exibido
const STORAGE_PREFIX = 'agpsi:recent-patients:';
const STORAGE_EVENT = 'agpsi:recent-patients:changed';
function storageKey(userId) {
return `${STORAGE_PREFIX}${userId || 'anon'}`;
}
function loadFromStorage(userId) {
try {
const raw = localStorage.getItem(storageKey(userId));
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function saveToStorage(userId, items) {
try {
localStorage.setItem(storageKey(userId), JSON.stringify(items));
// Notifica outras instâncias do composable nesta mesma aba
window.dispatchEvent(new CustomEvent(STORAGE_EVENT, { detail: { userId } }));
} catch {
// Quota cheia / modo privado — silenciar
}
}
/**
* @returns composable reativo com `items` (array de pacientes recentes),
* `addVisit(patient)` e `clear()`.
*
* Forma do patient esperado em addVisit:
* { id: string, nome: string, ... } extras (avatar, telefone, etc) são opt-in
*
* Forma do item armazenado:
* { id, nome, visited_at: ISO, extras: {} }
*/
export function useRecentPatients() {
const userId = ref(null);
const items = ref([]);
async function resolveUserId() {
if (userId.value) return userId.value;
const { data } = await supabase.auth.getUser();
userId.value = data?.user?.id || 'anon';
return userId.value;
}
async function refresh() {
const uid = await resolveUserId();
items.value = loadFromStorage(uid);
}
async function addVisit(patient) {
if (!patient?.id) return;
const uid = await resolveUserId();
const current = loadFromStorage(uid);
const entry = {
id: String(patient.id),
nome: patient.nome_completo || patient.nome_social || patient.nome || '(sem nome)',
visited_at: new Date().toISOString(),
extras: {
nome_social: patient.nome_social || null,
avatar_url: patient.avatar_url || null,
telefone: patient.telefone || null,
email: patient.email_principal || patient.email || null
}
};
// Remove duplicata + insere no topo + limita a MAX_ITEMS
const dedup = current.filter(x => x.id !== entry.id);
dedup.unshift(entry);
const trimmed = dedup.slice(0, MAX_ITEMS);
saveToStorage(uid, trimmed);
items.value = trimmed;
}
async function remove(patientId) {
const uid = await resolveUserId();
const current = loadFromStorage(uid);
const filtered = current.filter(x => String(x.id) !== String(patientId));
saveToStorage(uid, filtered);
items.value = filtered;
}
async function clear() {
const uid = await resolveUserId();
saveToStorage(uid, []);
items.value = [];
}
// Sincroniza entre instâncias do composable na mesma aba
function onChange() {
refresh();
}
function onStorage(ev) {
if (typeof ev?.key === 'string' && ev.key.startsWith(STORAGE_PREFIX)) {
refresh();
}
}
onMounted(() => {
refresh();
window.addEventListener(STORAGE_EVENT, onChange);
window.addEventListener('storage', onStorage);
});
onBeforeUnmount(() => {
window.removeEventListener(STORAGE_EVENT, onChange);
window.removeEventListener('storage', onStorage);
});
const hasItems = computed(() => items.value.length > 0);
return {
items,
hasItems,
addVisit,
remove,
clear,
refresh
};
}
// ── Stateless helpers — usáveis fora de componentes (ex: action handlers) ──
/**
* Registra uma visita SEM usar Vue reactivity. Útil pra hooks que não
* estão dentro de setup() (ex: router.beforeEach, navigation guards).
*/
export async function registerPatientVisit(patient) {
if (!patient?.id) return;
const { data } = await supabase.auth.getUser();
const uid = data?.user?.id || 'anon';
const current = loadFromStorage(uid);
const entry = {
id: String(patient.id),
nome: patient.nome_completo || patient.nome_social || patient.nome || '(sem nome)',
visited_at: new Date().toISOString(),
extras: {
nome_social: patient.nome_social || null,
avatar_url: patient.avatar_url || null,
telefone: patient.telefone || null,
email: patient.email_principal || patient.email || null
}
};
const dedup = current.filter(x => x.id !== entry.id);
dedup.unshift(entry);
saveToStorage(uid, dedup.slice(0, MAX_ITEMS));
}
@@ -0,0 +1,147 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/agenda/composables/useAgendaStatusChange.js
|
| Composable Tipo A que orquestra o fluxo de status change da agenda
| usando agendaBilling.service. Reusável em Melissa / Rail / Clínica.
|
| Uso:
| const { applyStatusChange, dialogOpen, dialogProps, onDialogConfirm,
| onDialogCancel } = useAgendaStatusChange({ toast });
|
| // No handler:
| await applyStatusChange({ eventoId, row, novoStatus });
|
| // No template:
| <AgendaStatusChangeConfirmDialog
| v-model="dialogOpen"
| :evento="dialogProps.evento"
| :novoStatus="dialogProps.novoStatus"
| :regraExcecao="dialogProps.regraExcecao"
| :billingContract="dialogProps.billingContract"
| :billingContractStyle="dialogProps.billingContractStyle"
| :pendingRecord="dialogProps.pendingRecord"
| :sessionPrice="dialogProps.sessionPrice"
| @confirm="onDialogConfirm"
| @update:modelValue="(v) => !v && onDialogCancel()"
| />
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import {
loadStatusChangeContext,
needsStatusConfirmDialog,
applyStatusDecisions
} from '@/features/agenda/services/agendaBilling.service';
/**
* @param {object} [opts]
* @param {object} [opts.toast] instância de useToast (PrimeVue). Opcional.
* @returns composable com state reativo + applyStatusChange
*/
export function useAgendaStatusChange({ toast = null } = {}) {
const tenantStore = useTenantStore();
// Dialog state — bindar no template
const dialogOpen = ref(false);
const dialogProps = ref({});
let _resolveDialog = null;
function _openDialog(propsObj) {
return new Promise((resolve) => {
dialogProps.value = propsObj;
dialogOpen.value = true;
_resolveDialog = resolve;
});
}
function onDialogConfirm(decision) {
if (_resolveDialog) _resolveDialog(decision);
_resolveDialog = null;
dialogOpen.value = false;
}
function onDialogCancel() {
if (_resolveDialog) _resolveDialog(null);
_resolveDialog = null;
dialogOpen.value = false;
}
/**
* Coordena: load context mostra dialog se preciso aplica decisões.
*
* @param {object} args
* @param {string} args.eventoId uuid (null pra ocorrências virtuais ainda)
* @param {object} args.row row do agenda_eventos (pode ser parcial)
* @param {string} args.novoStatus 'realizado' | 'faltou' | 'cancelado' | 'agendado'
*
* @returns {Promise<{ applied: boolean, decision: object|null, ctx: object }>}
* applied=true se passou pelo applyStatusDecisions.
* decision=null se user cancelou o dialog.
*/
async function applyStatusChange({ eventoId, row, novoStatus }) {
const ownerId = (await supabase.auth.getUser()).data?.user?.id || null;
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
// 1) Carrega contexto
const ctx = await loadStatusChangeContext({
supabase,
row,
eventoId,
status: novoStatus,
ownerId,
tenantId
});
// 2) Dialog se preciso
let decision = null;
if (needsStatusConfirmDialog(novoStatus, ctx)) {
decision = await _openDialog({
evento: row,
novoStatus,
regraExcecao: ctx.regraExcecao,
billingContract: ctx.billingContract,
billingContractStyle: ctx.billingContract?.charging_style || null,
pendingRecord: ctx.pendingRecord,
sessionPrice: row?.price ?? null
});
if (!decision) {
// user cancelou
return { applied: false, decision: null, ctx };
}
} else {
// Sem dialog — default decision vazia (só aplicar status change básico)
decision = {};
}
// 3) Aplica decisões
await applyStatusDecisions({
supabase,
toast,
eventoId,
row,
novoStatus,
ctx,
decision,
ownerId,
tenantId
});
return { applied: true, decision, ctx };
}
return {
// dialog state — pra template
dialogOpen,
dialogProps,
onDialogConfirm,
onDialogCancel,
// main action
applyStatusChange
};
}
+78 -28
View File
@@ -36,6 +36,12 @@ import { useDeterminedCommitments } from '@/features/agenda/composables/useDeter
import { useFeriados } from '@/composables/useFeriados';
import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios';
// Fase D (replicação Clínica): adopta agendaBilling.service via composable
// reutilizável. Cobre status change com confirm dialog + multa + reverse +
// pacote saldo/upfront (C7-C13 de Melissa, espelho da Fase C do Rail).
import { useAgendaStatusChange } from '@/features/agenda/composables/useAgendaStatusChange';
import AgendaStatusChangeConfirmDialog from '@/features/agenda/components/AgendaStatusChangeConfirmDialog.vue';
import { mapAgendaEventosToCalendarEvents, minutesToDuration } from '@/features/agenda/services/agendaMappers';
import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings';
import { useTenantStore } from '@/stores/tenantStore';
@@ -502,6 +508,15 @@ const ownerOptions = computed(() => staffCols.value.map((p) => ({ label: p.title
// -------------------- events --------------------
const { loading: loadingEvents, error: eventsError, rows, loadClinicRange, createClinic, updateClinic, removeClinic } = useAgendaClinicEvents();
// Fase D: status change com confirm dialog + billing (Melissa pattern).
const {
dialogOpen: statusDialogOpen,
dialogProps: statusDialogProps,
onDialogConfirm: onStatusDialogConfirm,
onDialogCancel: onStatusDialogCancel,
applyStatusChange
} = useAgendaStatusChange({ toast });
const { loadAndExpand, createRule, updateRule, cancelRule, splitRuleAt, cancelRuleFrom, upsertException } = useRecurrence();
const { saveRuleItems, propagateToSerie } = useCommitmentServices();
@@ -1189,38 +1204,58 @@ function onEditSeriesOccurrence({ id, recurrence_date, inicio_em, fim_em, is_vir
async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim_em, is_virtual }) {
const tid = tenantId.value;
try {
if (id) {
await updateClinic(id, { status }, { tenantId: tid });
return;
const row = dialogEventRow.value || {};
// 1) Materializar virtual se preciso (resolve eventoId real)
let eventoId = id;
if (!id) {
if (!is_virtual || !inicio_em) return;
const rid = row.recurrence_id ?? row.serie_id ?? null;
const rDate = recurrence_date || inicio_em?.slice(0, 10);
const { data: existing } = await supabase
.from('agenda_eventos')
.select('id')
.eq('recurrence_id', rid)
.eq('recurrence_date', rDate)
.maybeSingle();
if (existing?.id) {
eventoId = existing.id;
} else {
// Materializa com status='agendado'; o status final aplica
// após applyStatusChange ramificar pelo dialog se preciso.
const created = await createClinic(
{
owner_id: dialogOwnerId.value || clinicOwnerId.value,
tenant_id: tid,
recurrence_id: rid,
recurrence_date: rDate,
tipo: 'sessao',
status: 'agendado',
inicio_em,
fim_em,
visibility_scope: 'public',
titulo: row.titulo || 'Sessão',
patient_id: row.patient_id || row.paciente_id || null,
determined_commitment_id: row.determined_commitment_id || null
},
{ tenantId: tid }
);
eventoId = created?.id || null;
}
}
if (!is_virtual || !inicio_em) return;
const rid = dialogEventRow.value?.recurrence_id ?? dialogEventRow.value?.serie_id ?? null;
const rDate = recurrence_date || inicio_em?.slice(0, 10);
// 2) Atualiza status no DB
if (eventoId) {
await updateClinic(eventoId, { status }, { tenantId: tid });
}
const { data: existing } = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', rid).eq('recurrence_date', rDate).maybeSingle();
// 3) Fluxo de billing (load context + dialog + apply)
const rowWithStatus = { ...row, id: eventoId, status, inicio_em, fim_em };
const { applied } = await applyStatusChange({ eventoId, row: rowWithStatus, novoStatus: status });
if (existing?.id) {
await updateClinic(existing.id, { status }, { tenantId: tid });
} else {
const row = dialogEventRow.value || {};
await createClinic(
{
owner_id: dialogOwnerId.value || clinicOwnerId.value,
tenant_id: tid,
recurrence_id: rid,
recurrence_date: rDate,
tipo: 'sessao',
status,
inicio_em,
fim_em,
visibility_scope: 'public',
titulo: row.titulo || 'Sessão',
patient_id: row.patient_id || row.paciente_id || null,
determined_commitment_id: row.determined_commitment_id || null
},
{ tenantId: tid }
);
// 4) Refetch se aplicou (UI reflete novo estado)
if (applied && typeof loadClinicRange === 'function') {
await loadClinicRange();
}
} catch (e) {
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao atualizar status.', life: 3000 });
@@ -2463,6 +2498,21 @@ function goRecorrencias() {
<!-- Dialog de Bloqueio -->
<BloqueioDialog v-model="bloqueioDialogOpen" :mode="bloqueioMode" :workRules="workRules" :settings="settings" :ownerId="clinicOwnerId" :tenantId="tenantId || ''" @saved="refetch" />
<!-- Fase D: confirma status change com decisões de billing
(multa, consumir saldo, gerar cobrança, reverse transition). -->
<AgendaStatusChangeConfirmDialog
v-model="statusDialogOpen"
:evento="statusDialogProps.evento"
:novoStatus="statusDialogProps.novoStatus"
:regraExcecao="statusDialogProps.regraExcecao"
:billingContract="statusDialogProps.billingContract"
:billingContractStyle="statusDialogProps.billingContractStyle"
:pendingRecord="statusDialogProps.pendingRecord"
:sessionPrice="statusDialogProps.sessionPrice"
@confirm="onStatusDialogConfirm"
@update:modelValue="(v) => !v && onStatusDialogCancel()"
/>
<!-- Dialog: feriados próximos (todos os dias úteis bloqueados e pendentes) -->
<Dialog v-model:visible="feriadosAlertaOpen" modal :draggable="false" header="Feriados nos próximos 30 dias" :style="{ width: '520px', maxWidth: '96vw' }">
<div class="flex flex-col gap-3">
@@ -49,6 +49,13 @@ import { useDeterminedCommitments } from '@/features/agenda/composables/useDeter
import { useFeriados } from '@/composables/useFeriados';
import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios';
// Fase C (replicação Rail): adopta agendaBilling.service via composable
// reutilizável. Cobre status change com confirm dialog + multa + reverse +
// pacote saldo/upfront (C7-C13 de Melissa).
import { useAgendaStatusChange } from '@/features/agenda/composables/useAgendaStatusChange';
import { createPackageContract, materializeAndChargePerSession } from '@/features/agenda/services/agendaBilling.service';
import AgendaStatusChangeConfirmDialog from '@/features/agenda/components/AgendaStatusChangeConfirmDialog.vue';
import { mapAgendaEventosToCalendarEvents, buildWeeklyBreakBackgroundEvents, minutesToDuration } from '@/features/agenda/services/agendaMappers';
const router = useRouter();
@@ -119,6 +126,16 @@ watch(eventsLoading, (val) => {
if (!val) eventsHasLoaded.value = true;
});
// Fase C: orquestrador de status change (Melissa pattern). Cobre confirm
// dialog + multa + reverse + pacote saldo/upfront via agendaBilling.service.
const {
dialogOpen: statusDialogOpen,
dialogProps: statusDialogProps,
onDialogConfirm: onStatusDialogConfirm,
onDialogCancel: onStatusDialogCancel,
applyStatusChange
} = useAgendaStatusChange({ toast });
const { loadAndExpand, createRule, updateRule, cancelRule, splitRuleAt, cancelRuleFrom, upsertException } = useRecurrence();
const { saveRuleItems, propagateToSerie } = useCommitmentServices();
@@ -1711,37 +1728,60 @@ function onEditSeriesOccurrence({ id, recurrence_date, inicio_em, fim_em, is_vir
async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim_em, is_virtual }) {
try {
if (id) {
await update(id, { status });
return;
const row = dialogEventRow.value || {};
// 1) Materializar virtual se preciso (resolve `eventoId` real)
let eventoId = id;
if (!id) {
if (!is_virtual || !inicio_em) return;
const rid = row.recurrence_id ?? row.serie_id ?? null;
const rDate = recurrence_date || inicio_em?.slice(0, 10);
const { data: existing } = await supabase
.from('agenda_eventos')
.select('id')
.eq('recurrence_id', rid)
.eq('recurrence_date', rDate)
.maybeSingle();
if (existing?.id) {
eventoId = existing.id;
// Status atualiza só depois do dialog/applyStatusChange decidir
} else {
// Materializa criando com status='agendado' o status final
// é aplicado por applyStatusChange (que pode ramificar pelo
// dialog se houver decisão a tomar)
const created = await create({
owner_id: ownerId.value,
tenant_id: clinicTenantId.value,
recurrence_id: rid,
recurrence_date: rDate,
tipo: 'sessao',
status: 'agendado',
inicio_em,
fim_em,
visibility_scope: 'public',
titulo: row.titulo || 'Sessão',
patient_id: row.patient_id || row.paciente_id || null,
determined_commitment_id: row.determined_commitment_id || null,
price: row.price ?? null
});
eventoId = created?.id || null;
}
}
if (!is_virtual || !inicio_em) return;
const rid = dialogEventRow.value?.recurrence_id ?? dialogEventRow.value?.serie_id ?? null;
const rDate = recurrence_date || inicio_em?.slice(0, 10);
// 2) Atualiza status NO DB (applyStatusChange só cuida de billing não
// do status do agenda_evento em si). Antes do dialog pra ctx.row
// refletir o novo status do evento.
if (eventoId) {
await update(eventoId, { status });
}
// Verifica se já foi materializado antes (evita violação de constraint)
const { data: existing } = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', rid).eq('recurrence_date', rDate).maybeSingle();
// 3) Aplica fluxo de billing (load context + dialog se preciso + apply)
const rowWithStatus = { ...row, id: eventoId, status, inicio_em, fim_em };
const { applied } = await applyStatusChange({ eventoId, row: rowWithStatus, novoStatus: status });
if (existing?.id) {
await update(existing.id, { status });
} else {
const row = dialogEventRow.value || {};
await create({
owner_id: ownerId.value,
tenant_id: clinicTenantId.value,
recurrence_id: rid,
recurrence_date: rDate,
tipo: 'sessao',
status,
inicio_em,
fim_em,
visibility_scope: 'public',
titulo: row.titulo || 'Sessão',
patient_id: row.patient_id || row.paciente_id || null,
determined_commitment_id: row.determined_commitment_id || null,
price: row.price ?? null
});
// 4) Se aplicou (e não cancelou via dialog), refetch pra UI refletir
if (applied) {
await loadMyRange?.();
}
} catch (e) {
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao atualizar status.', life: 3000 });
@@ -3303,6 +3343,21 @@ onBeforeUnmount(() => {
<!-- Dialog: Prontuário -->
<PatientProntuario :key="selectedPatient?.id || 'none'" v-model="prontuarioOpen" :patient="selectedPatient" @close="closeProntuario" />
<!-- Fase C: confirma status change com decisões de billing
(multa, consumir saldo, gerar cobrança, reverse transition). -->
<AgendaStatusChangeConfirmDialog
v-model="statusDialogOpen"
:evento="statusDialogProps.evento"
:novoStatus="statusDialogProps.novoStatus"
:regraExcecao="statusDialogProps.regraExcecao"
:billingContract="statusDialogProps.billingContract"
:billingContractStyle="statusDialogProps.billingContractStyle"
:pendingRecord="statusDialogProps.pendingRecord"
:sessionPrice="statusDialogProps.sessionPrice"
@confirm="onStatusDialogConfirm"
@update:modelValue="(v) => !v && onStatusDialogCancel()"
/>
</template>
<style scoped>
@@ -0,0 +1,816 @@
/*
|--------------------------------------------------------------------------
| Agência PSI agendaBilling service (Fase B1)
|--------------------------------------------------------------------------
| Helpers e loaders relacionados a billing da agenda, extraídos de
| useMelissaAgenda.js pra serem reusados em Rail/Clínica.
|
| Esta sessão (Fase B1) cobre read-only + helpers puros:
| - computeSeriePrice (puro)
| - generateOccurrenceDates (puro)
| - loadStatusChangeContext (read-only DB)
| - needsStatusConfirmDialog (puro)
|
| Fase B2 (mutations) extrairá: applyStatusDecisions, createPackageContract,
| materializeAndChargePerSession.
|
| Convenção: funções recebem `supabase` explícito (não usa import direto)
| pra facilitar teste + reuso fora do contexto Vue. Nenhuma função aqui
| dispara toast caller decide.
|--------------------------------------------------------------------------
*/
import { dateToISO } from '@/features/agenda/utils/timeHelpers';
// ── Helpers puros ─────────────────────────────────────────────────────────
/**
* Calcula o valor total da série a partir dos commitmentItems.
*
* @param {object} recorrencia { qtdSessoes, commitmentItems, serieValorMode }
* @returns {{ n, perSessao, packagePrice }}
*/
export function computeSeriePrice(recorrencia) {
const items = recorrencia?.commitmentItems || [];
const n = recorrencia?.qtdSessoes || 1;
const totalPorSessao = items.reduce((s, i) => s + (i.final_price ?? 0), 0);
const pacoteFechado = recorrencia?.serieValorMode === 'dividir';
return {
n,
perSessao: pacoteFechado ? totalPorSessao / n : totalPorSessao,
packagePrice: pacoteFechado ? totalPorSessao : totalPorSessao * n
};
}
/**
* Gera lista de datas ISO ('YYYY-MM-DD') a partir de uma rule de recorrência.
* Pula datas em exceptionDates (Set). Para até `max` datas. Suporta weekly
* (interval=1 ou 2 pra quinzenal) e custom_weekdays.
*
* @param {object} rule { start_date, interval, weekdays, type }
* @param {number} max
* @param {Set<string>} exceptionDates
* @returns {string[]}
*/
export function generateOccurrenceDates(rule, max, exceptionDates = new Set()) {
const dates = [];
const start = new Date(`${rule.start_date}T00:00:00`);
const interval = Math.max(1, rule.interval || 1);
const weekdays = Array.isArray(rule.weekdays) && rule.weekdays.length
? rule.weekdays.map(Number)
: [start.getDay()];
const isCustom = rule.type === 'custom_weekdays';
const cursor = new Date(start);
let safety = 0;
while (dates.length < max && safety < 365 * 3) {
const iso = dateToISO(cursor);
const dow = cursor.getDay();
const inWeekdays = weekdays.includes(dow);
if (inWeekdays && !exceptionDates.has(iso)) {
dates.push(iso);
}
if (isCustom) {
cursor.setDate(cursor.getDate() + 1);
} else if (inWeekdays) {
cursor.setDate(cursor.getDate() + 7 * interval);
} else {
cursor.setDate(cursor.getDate() + 1);
}
safety++;
}
return dates;
}
/**
* Decide se o dialog de confirmação de status change deve ser exibido.
*
* Pure: depende do ctx montado por loadStatusChangeContext.
*
* Regras:
* - faltou/cancelado: mostra se regra de exceção com charge_mode != 'none'
* OU pacote saldo/upfront
* - realizado: mostra se pending record OU pacote saldo
* - agendado: (reverse) mostra se artefatos a desfazer
*/
export function needsStatusConfirmDialog(status, ctx) {
const isFaltouOrCancel = status === 'faltou' || status === 'cancelado';
const isRealizado = status === 'realizado';
const isAgendado = status === 'agendado';
const hasRegraComCobranca = ctx?.regraExcecao && ctx.regraExcecao.charge_mode !== 'none';
const isPacoteSaldo = ctx?.billingContract?.charging_style === 'saldo';
const isPacoteUpfront = ctx?.billingContract?.charging_style === 'upfront';
const hasPending = !!ctx?.pendingRecord;
if (isFaltouOrCancel) {
return hasRegraComCobranca || isPacoteSaldo || isPacoteUpfront;
}
if (isRealizado) {
return hasPending || isPacoteSaldo;
}
if (isAgendado) {
const r = ctx?.reverseArtifacts;
if (!r) return false;
const hasActiveRecords = (r.activeRecords?.length || 0) > 0;
return hasActiveRecords || r.saldoConsumed;
}
return false;
}
// ── Loaders (read-only DB) ────────────────────────────────────────────────
/**
* Carrega contexto pra decisão de status change.
*
* Read-only. Não dispara toast (caller decide). Tolerante a erros parciais
* (loga warn e segue com null).
*
* @param {object} opts
* @param {object} opts.supabase instância do client
* @param {object} opts.row row do agenda_eventos (pode ser parcial usa fallbacks)
* @param {string} opts.eventoId uuid (null pra ocorrências virtuais não materializadas)
* @param {string} opts.status 'realizado' | 'faltou' | 'cancelado' | 'agendado'
* @param {string} opts.ownerId auth.uid() (resolvido pelo caller)
* @param {string} opts.tenantId activeTenantId
*
* @returns {Promise<{
* regraExcecao,
* billingContract,
* pendingRecord,
* existingPaidRecord,
* reverseArtifacts: { previousStatus, activeRecords, saldoConsumed } | null
* }>}
*/
export async function loadStatusChangeContext({ supabase, row, eventoId, status, ownerId, tenantId }) {
const ctx = {
regraExcecao: null,
billingContract: null,
pendingRecord: null,
existingPaidRecord: null,
reverseArtifacts: null
};
// 1) Regra de exceção (faltou → patient_no_show, cancelado → patient_cancellation)
const exceptionTypeMap = { faltou: 'patient_no_show', cancelado: 'patient_cancellation' };
const excType = exceptionTypeMap[status];
if (excType && tenantId) {
try {
const { data } = await supabase
.from('financial_exceptions')
.select('*')
.eq('tenant_id', tenantId)
.eq('exception_type', excType)
.or(`owner_id.eq.${ownerId},owner_id.is.null`)
.order('owner_id', { ascending: false, nullsLast: true })
.limit(1)
.maybeSingle();
ctx.regraExcecao = data ?? null;
} catch (e) {
console.warn('[agendaBilling] regra de exceção:', e?.message);
}
}
// 2) Billing contract — 3 caminhos: row.billing_contract_id direto → query
// agenda_eventos.billing_contract_id (recém-materializada) → contrato
// ativo do paciente (virtuais).
const patientId = row?.patient_id ?? row?.paciente_id ?? null;
const contractId = row?.billing_contract_id ?? null;
if (contractId) {
try {
const { data } = await supabase
.from('billing_contracts')
.select('*')
.eq('id', contractId)
.maybeSingle();
ctx.billingContract = data ?? null;
} catch (e) {
console.warn('[agendaBilling] contract via id direto:', e?.message);
}
}
if (!ctx.billingContract && eventoId) {
try {
const { data: ev } = await supabase
.from('agenda_eventos')
.select('billing_contract_id')
.eq('id', eventoId)
.maybeSingle();
if (ev?.billing_contract_id) {
const { data: c } = await supabase
.from('billing_contracts')
.select('*')
.eq('id', ev.billing_contract_id)
.maybeSingle();
ctx.billingContract = c ?? null;
}
} catch (e) {
console.warn('[agendaBilling] contract via agenda_evento:', e?.message);
}
}
if (!ctx.billingContract && patientId && tenantId) {
try {
const { data: c } = await supabase
.from('billing_contracts')
.select('*')
.eq('tenant_id', tenantId)
.eq('patient_id', patientId)
.eq('status', 'active')
.eq('type', 'package')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
ctx.billingContract = c ?? null;
} catch (e) {
console.warn('[agendaBilling] contract via patient_id:', e?.message);
}
}
// 3) Pending record
if (eventoId) {
try {
const { data } = await supabase
.from('financial_records')
.select('*')
.eq('agenda_evento_id', eventoId)
.in('status', ['pending', 'overdue'])
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
ctx.pendingRecord = data ?? null;
} catch (e) {
console.warn('[agendaBilling] pending record:', e?.message);
}
}
// 3b) Paid record pré-existente (caso C12: antecipar pagamento).
if (eventoId) {
try {
const { data } = await supabase
.from('financial_records')
.select('id, status, amount, final_amount, paid_at, payment_method')
.eq('agenda_evento_id', eventoId)
.eq('status', 'paid')
.order('paid_at', { ascending: false })
.limit(1)
.maybeSingle();
ctx.existingPaidRecord = data ?? null;
} catch (e) {
console.warn('[agendaBilling] existing paid record:', e?.message);
}
}
// 4) Reverse transition (status novo='agendado'): artefatos a desfazer.
if (status === 'agendado' && eventoId) {
ctx.reverseArtifacts = {
previousStatus: row?.status || null,
activeRecords: [],
saldoConsumed: false
};
try {
const { data: evRow } = await supabase
.from('agenda_eventos')
.select('status, billing_contract_id')
.eq('id', eventoId)
.maybeSingle();
if (evRow) {
ctx.reverseArtifacts.previousStatus = evRow.status;
}
const { data: recs } = await supabase
.from('financial_records')
.select('id, status, amount, final_amount, description, paid_at, payment_method')
.eq('agenda_evento_id', eventoId)
.neq('status', 'cancelled')
.order('created_at', { ascending: false });
ctx.reverseArtifacts.activeRecords = recs || [];
// Heurística saldo consumido: billing_contract_id + previousStatus
// ≠ 'agendado' + style=saldo. Falso positivo é mitigado pela escolha
// do user no dialog de "devolver saldo".
const wasInActiveState = evRow?.billing_contract_id && evRow.status && evRow.status !== 'agendado';
ctx.reverseArtifacts.saldoConsumed = wasInActiveState && ctx.billingContract?.charging_style === 'saldo';
} catch (e) {
console.warn('[agendaBilling] reverse artifacts:', e?.message);
}
}
return ctx;
}
// ── Mutations (Fase B2 — side effects DB) ─────────────────────────────────
const _BRL = (v) => Number(v ?? 0).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
/**
* Aplica as decisões tomadas no dialog de status change (reverse / consume
* saldo / multa / mark paid / cobrança pacote).
*
* Recebe deps explícitas (supabase, toast, ownerId, tenantId) em vez de
* capturar via closure. Toast pode ser null quando chamado fora de UI
* (ex: background job), erros viram exceções no caller.
*
* Mantém a lógica idêntica à versão inline original em useMelissaAgenda.
*
* @param {object} opts
* @param {object} opts.supabase
* @param {object} [opts.toast] `{ add: fn }`. Opcional.
* @param {string} opts.eventoId
* @param {object} opts.row
* @param {string} opts.novoStatus
* @param {object} opts.ctx saída de loadStatusChangeContext
* @param {object} opts.decision
* @param {string} opts.ownerId
* @param {string} opts.tenantId
*/
export async function applyStatusDecisions({ supabase, toast, eventoId, row, novoStatus, ctx, decision, ownerId, tenantId }) {
const uid = ownerId;
const patientId = row?.patient_id ?? row?.paciente_id ?? null;
const tasks = [];
const tx = (entry) => { if (toast?.add) toast.add(entry); };
// ─── REVERSE TRANSITION (novoStatus='agendado' com artefatos a desfazer) ──
if (novoStatus === 'agendado' && ctx.reverseArtifacts) {
const r = ctx.reverseArtifacts;
// 1) Cancelar records pending/overdue
if (decision.reverseCancelPending && (r.activeRecords?.length || 0) > 0) {
const pendingIds = r.activeRecords.filter((rec) => rec.status === 'pending' || rec.status === 'overdue').map((rec) => rec.id);
if (pendingIds.length > 0) {
try {
const today = new Date().toISOString().slice(0, 10);
const reason = `Cancelada via reversão de status (${r.previousStatus} → agendado) em ${today}`;
for (const id of pendingIds) {
const { error: cErr } = await supabase
.from('financial_records')
.update({
status: 'cancelled',
notes: `[${today}] ${reason}`,
updated_at: new Date().toISOString()
})
.eq('id', id);
if (cErr) throw cErr;
}
} catch (e) {
console.error('[agendaBilling/reverse] erro cancelando records:', e?.message);
tx({ severity: 'warn', summary: 'Cobrança', detail: e?.message || 'Falha ao cancelar cobranças', life: 5000 });
}
}
}
// 2) Devolver saldo
if (decision.reverseRestoreSaldo && r.saldoConsumed && ctx.billingContract?.id) {
try {
const { data: freshContract, error: fetchErr } = await supabase
.from('billing_contracts')
.select('sessions_used, total_sessions, status')
.eq('id', ctx.billingContract.id)
.maybeSingle();
if (fetchErr) throw fetchErr;
const currentUsed = freshContract?.sessions_used ?? 0;
const totalSessions = freshContract?.total_sessions ?? 0;
const newUsed = Math.max(0, currentUsed - 1);
const patch = { sessions_used: newUsed };
if (currentUsed >= totalSessions) {
patch.status = 'active';
}
const { error: dErr } = await supabase.from('billing_contracts').update(patch).eq('id', ctx.billingContract.id);
if (dErr) throw dErr;
} catch (e) {
console.error('[agendaBilling/reverse] erro decrementando saldo:', e?.message);
tx({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao devolver saldo', life: 5000 });
}
}
// 3) Desamarrar billing_contract_id (só se devolveu saldo)
if (decision.reverseRestoreSaldo && r.saldoConsumed) {
try {
await supabase.from('agenda_eventos').update({ billing_contract_id: null, updated_at: new Date().toISOString() }).eq('id', eventoId);
} catch (e) {
console.warn('[agendaBilling/reverse] erro desamarrando billing_contract_id:', e?.message);
}
}
tx({ severity: 'success', summary: 'Sessão reativada', detail: 'Status revertido pra agendada.', life: 2500 });
return;
}
// 1) Consumir saldo
if (decision.consumeSaldo && ctx.billingContract?.id) {
tasks.push(
supabase
.from('billing_contracts')
.update({ sessions_used: (ctx.billingContract.sessions_used ?? 0) + 1 })
.eq('id', ctx.billingContract.id)
);
}
// 1b) Amarra evento ao contrato (universal pra forward em pacote)
const isForwardStatus = novoStatus === 'realizado' || novoStatus === 'faltou' || novoStatus === 'cancelado';
if (isForwardStatus && ctx.billingContract?.id && eventoId) {
tasks.push(
supabase
.from('agenda_eventos')
.update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() })
.eq('id', eventoId)
);
}
// 2) Aplicar multa
if (decision.applyFine && decision.fineAmount > 0) {
const dueIso = row.inicio_em ? new Date(row.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
const sessaoLabel = row.inicio_em ? new Date(row.inicio_em).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit' }) : '';
const fineDesc = novoStatus === 'faltou' ? `Multa por falta · sessão ${sessaoLabel}` : `Taxa de cancelamento tardio · sessão ${sessaoLabel}`;
const finePayload = {
owner_id: uid,
tenant_id: tenantId,
patient_id: patientId,
agenda_evento_id: eventoId,
amount: decision.fineAmount,
final_amount: decision.fineAmount,
description: fineDesc.trim(),
status: 'pending',
due_date: dueIso,
type: 'receita'
};
tasks.push(
supabase
.from('financial_records')
.insert(finePayload)
.then(({ error }) => {
if (error) {
console.warn('[agendaBilling] INSERT multa falhou:', error?.message, 'payload:', finePayload);
throw error;
}
})
);
}
// 2b) Cancelar cobrança original (faltou/cancelado + pendingRecord)
const isFaltouOuCancelado = novoStatus === 'faltou' || novoStatus === 'cancelado';
if (isFaltouOuCancelado && ctx.pendingRecord?.id) {
const reasonText = decision.applyFine
? novoStatus === 'faltou'
? 'Cancelada — substituída por multa de no-show'
: 'Cancelada — substituída por taxa de cancelamento tardio'
: novoStatus === 'faltou'
? 'Cancelada — sessão não realizada (paciente faltou)'
: 'Cancelada — sessão cancelada';
const today = new Date().toISOString().slice(0, 10);
const noteEntry = `[${today}] ${reasonText}`;
const noteText = ctx.pendingRecord.notes ? `${ctx.pendingRecord.notes}\n${noteEntry}` : noteEntry;
tasks.push(
supabase
.from('financial_records')
.update({
status: 'cancelled',
notes: noteText,
updated_at: new Date().toISOString()
})
.eq('id', ctx.pendingRecord.id)
);
}
// 3) Realizado avulsa pendente: marcar pendingRecord como pago (ou só status)
if (decision.markPaid && ctx.pendingRecord?.id) {
tasks.push(
supabase
.from('financial_records')
.update({
status: 'paid',
paid_at: new Date().toISOString(),
payment_method: decision.paymentMethod || 'pix',
updated_at: new Date().toISOString()
})
.eq('id', ctx.pendingRecord.id)
);
}
// 4-pre) Realizado em pacote saldo + paid pré-existente (C12)
const hasAnticipatedPayment = novoStatus === 'realizado' && ctx.billingContract?.charging_style === 'saldo' && ctx.existingPaidRecord?.id;
if (hasAnticipatedPayment) {
if (tasks.length > 0) {
const results = await Promise.allSettled(tasks);
const failed = results.filter((r) => r.status === 'rejected');
if (failed.length > 0) {
console.warn('[agendaBilling/realizada-paid] tasks com falha:', failed.map((f) => f.reason?.message));
}
}
try {
const { data: freshContract, error: fetchErr } = await supabase
.from('billing_contracts')
.select('sessions_used, total_sessions, status')
.eq('id', ctx.billingContract.id)
.maybeSingle();
if (fetchErr) throw fetchErr;
const currentUsed = freshContract?.sessions_used ?? 0;
const newUsed = currentUsed + 1;
const patch = { sessions_used: newUsed };
if (newUsed >= (freshContract?.total_sessions ?? 0)) {
patch.status = 'completed';
}
const { error: incErr } = await supabase.from('billing_contracts').update(patch).eq('id', ctx.billingContract.id);
if (incErr) throw incErr;
tx({ severity: 'success', summary: 'Sessão consumida', detail: `Saldo: ${newUsed}/${freshContract?.total_sessions ?? '?'}. Pagamento já estava registrado.`, life: 4000 });
} catch (e) {
console.error('[agendaBilling/realizada-paid] erro consumindo saldo:', e?.message);
tx({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao consumir saldo', life: 5000 });
}
return;
}
// 4) Realizado em pacote saldo: amarra + cria cobrança + incrementa saldo
if (decision.generatePackageCharge && ctx.billingContract?.id) {
const amount = Number(row.price ?? (ctx.billingContract.total_sessions > 0 ? (Number(ctx.billingContract.package_price) || 0) / ctx.billingContract.total_sessions : 0));
const dueIso = row.inicio_em ? new Date(row.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
try {
const { error: linkErr } = await supabase.from('agenda_eventos').update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() }).eq('id', eventoId);
if (linkErr) throw linkErr;
} catch (e) {
console.error('[agendaBilling] erro amarrando billing_contract_id:', e?.message);
tx({ severity: 'warn', summary: 'Pacote — amarração', detail: e?.message || 'Falha ao amarrar sessão ao pacote', life: 5000 });
}
try {
const { error: rpcErr } = await supabase.rpc('create_financial_record_for_session', {
p_tenant_id: tenantId,
p_owner_id: uid,
p_patient_id: patientId,
p_agenda_evento_id: eventoId,
p_amount: amount,
p_due_date: dueIso
});
if (rpcErr) throw rpcErr;
} catch (e) {
console.error('[agendaBilling] erro RPC create_financial_record_for_session:', e?.message);
tx({ severity: 'error', summary: 'Cobrança', detail: e?.message || 'Falha ao gerar cobrança do pacote', life: 7000 });
}
try {
const newUsed = (ctx.billingContract.sessions_used ?? 0) + 1;
const patchContract = { sessions_used: newUsed };
if (newUsed >= (ctx.billingContract.total_sessions ?? 0)) {
patchContract.status = 'completed';
}
const { error: incErr } = await supabase.from('billing_contracts').update(patchContract).eq('id', ctx.billingContract.id);
if (incErr) throw incErr;
} catch (e) {
console.error('[agendaBilling] erro incrementando sessions_used:', e?.message);
tx({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao atualizar saldo do pacote', life: 6000 });
}
}
// Roda tudo em paralelo
const results = await Promise.allSettled(tasks);
const failed = results.filter((r) => r.status === 'rejected');
if (failed.length > 0) {
const firstErr = failed[0].reason?.message || 'sem detalhe';
tx({ severity: 'error', summary: 'Erro ao aplicar decisões', detail: `${failed.length} ação(ões) falharam: ${firstErr}`, life: 7000 });
console.error('[agendaBilling] falhas em applyStatusDecisions:', failed.map((f) => f.reason));
} else if (tasks.length > 0) {
tx({ severity: 'success', summary: 'Status atualizado', detail: 'Decisões aplicadas.', life: 2500 });
}
// Pós-processamento do record gerado pelo pacote saldo
if (decision.generatePackageCharge && eventoId) {
try {
const { data: newRec } = await supabase
.from('financial_records')
.select('id')
.eq('agenda_evento_id', eventoId)
.order('created_at', { ascending: false })
.limit(1)
.single();
if (newRec?.id) {
if (decision.markPaid) {
await supabase
.from('financial_records')
.update({
status: 'paid',
paid_at: new Date().toISOString(),
payment_method: decision.paymentMethod,
updated_at: new Date().toISOString()
})
.eq('id', newRec.id);
} else if (decision.paymentMethod === 'link') {
await supabase
.from('financial_records')
.update({ payment_method: 'asaas', updated_at: new Date().toISOString() })
.eq('id', newRec.id);
}
}
} catch { /* silencioso */ }
}
}
/**
* Cria billing_contract de pacote (upfront ou saldo). Materializa 1ª
* ocorrência + 1 financial_record (estilo upfront), ou o contrato
* (estilo saldo).
*
* Retorna { toast: { severity, summary, detail, life } } caller mostra.
*/
export async function createPackageContract({ supabase, rule, normalized, recorrencia, tenantId, packageStyle = 'upfront', paymentMethod = 'link', markPaidNow = false }) {
const { n, packagePrice } = computeSeriePrice(recorrencia);
try {
const { data: createdContract, error: contractErr } = await supabase
.from('billing_contracts')
.insert({
owner_id: normalized.owner_id,
tenant_id: tenantId,
patient_id: normalized.paciente_id,
type: 'package',
total_sessions: n,
sessions_used: 0,
package_price: packagePrice,
status: 'active',
charging_style: packageStyle === 'saldo' ? 'saldo' : 'upfront'
})
.select('id')
.single();
if (contractErr) throw contractErr;
const contractId = createdContract?.id ?? null;
if (packageStyle === 'saldo') {
return {
toast: {
severity: 'success',
summary: 'Pacote criado (saldo)',
detail: `${n} sessões — total ${_BRL(packagePrice)}. Cobranças individuais conforme sessões.`,
life: 3500
}
};
}
const durMin = rule.duration_min || 50;
const [hh, mm] = String(rule.start_time || '00:00:00').slice(0, 5).split(':').map(Number);
const firstISO = rule.start_date;
const startDt = new Date(`${firstISO}T00:00:00`);
startDt.setHours(hh, mm, 0, 0);
const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
const { data: createdEvent, error: evErr } = await supabase
.from('agenda_eventos')
.insert({
owner_id: rule.owner_id,
tenant_id: tenantId,
terapeuta_id: rule.therapist_id ?? null,
recurrence_id: rule.id,
recurrence_date: firstISO,
tipo: 'sessao',
status: 'agendado',
titulo: normalized.titulo || 'Sessão',
inicio_em: startDt.toISOString(),
fim_em: endDt.toISOString(),
patient_id: normalized.paciente_id,
determined_commitment_id: normalized.determined_commitment_id ?? null,
modalidade: rule.modalidade || normalized.modalidade || 'presencial',
price: packagePrice,
billing_contract_id: contractId,
visibility_scope: normalized.visibility_scope || 'public'
})
.select('id')
.single();
if (evErr) throw evErr;
const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', {
p_tenant_id: tenantId,
p_owner_id: rule.owner_id,
p_patient_id: normalized.paciente_id ?? null,
p_agenda_evento_id: createdEvent.id,
p_amount: packagePrice,
p_due_date: firstISO
});
if (cobErr) throw cobErr;
const paidNow = markPaidNow === true && paymentMethod !== 'link';
const { data: recRow } = await supabase
.from('financial_records')
.select('id')
.eq('agenda_evento_id', createdEvent.id)
.order('created_at', { ascending: false })
.limit(1)
.single();
if (recRow?.id) {
const patch = {
updated_at: new Date().toISOString(),
payment_method: paymentMethod === 'link' ? 'asaas' : paymentMethod
};
if (paidNow) {
patch.status = 'paid';
patch.paid_at = new Date().toISOString();
}
await supabase.from('financial_records').update(patch).eq('id', recRow.id);
}
const methodLabel = {
pix: 'PIX',
dinheiro: 'dinheiro',
deposito: 'depósito',
cartao_maquininha: 'cartão (maquininha)'
}[paymentMethod] || null;
return {
toast: {
severity: 'success',
summary: paidNow ? 'Pacote pago' : 'Pacote criado',
detail: paidNow
? `${n} sessões — ${_BRL(packagePrice)} recebido via ${methodLabel}.`
: `${n} sessões — total ${_BRL(packagePrice)} com vencimento em ${firstISO.split('-').reverse().join('/')}.`,
life: 4000
}
};
} catch (e) {
return {
toast: {
severity: 'warn',
summary: 'Pacote não gerado',
detail: e?.message || 'Falha ao criar contrato. Você pode gerar manualmente pelo Financeiro.',
life: 5000
}
};
}
}
/**
* chargeMode='per_session': materializa todas as N ocorrências + 1 financial_record
* por ocorrência. Falha parcial é tolerada (retorna toast warn).
*/
export async function materializeAndChargePerSession({ supabase, rule, normalized, recorrencia, tenantId }) {
const { n, perSessao } = computeSeriePrice(recorrencia);
try {
const exceptionDates = new Set((recorrencia.conflitos || []).map((c) => c.date));
const dates = generateOccurrenceDates(rule, n + exceptionDates.size, exceptionDates).slice(0, n);
const durMin = rule.duration_min || 50;
const [hh, mm] = String(rule.start_time || '00:00:00').slice(0, 5).split(':').map(Number);
const rows = dates.map((iso) => {
const startDt = new Date(`${iso}T00:00:00`);
startDt.setHours(hh, mm, 0, 0);
const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
return {
owner_id: rule.owner_id,
tenant_id: tenantId,
terapeuta_id: rule.therapist_id ?? null,
recurrence_id: rule.id,
recurrence_date: iso,
tipo: 'sessao',
status: 'agendado',
titulo: normalized.titulo || 'Sessão',
inicio_em: startDt.toISOString(),
fim_em: endDt.toISOString(),
patient_id: normalized.paciente_id,
determined_commitment_id: normalized.determined_commitment_id ?? null,
modalidade: rule.modalidade || normalized.modalidade || 'presencial',
price: perSessao,
visibility_scope: normalized.visibility_scope || 'public'
};
});
const { data: createdEvents, error: evErr } = await supabase.from('agenda_eventos').insert(rows).select('id, inicio_em');
if (evErr) throw evErr;
let okCount = 0;
let failCount = 0;
for (const ev of createdEvents || []) {
try {
const dueDate = ev.inicio_em ? new Date(ev.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', {
p_tenant_id: tenantId,
p_owner_id: rule.owner_id,
p_patient_id: normalized.paciente_id ?? null,
p_agenda_evento_id: ev.id,
p_amount: perSessao,
p_due_date: dueDate
});
if (cobErr) throw cobErr;
okCount++;
} catch {
failCount++;
}
}
if (failCount === 0) {
return {
toast: {
severity: 'success',
summary: `${okCount} cobranças geradas`,
detail: `${_BRL(perSessao)} por sessão. Total: ${_BRL(perSessao * okCount)}.`,
life: 4000
}
};
}
return {
toast: {
severity: 'warn',
summary: 'Cobranças parcialmente geradas',
detail: `${okCount} ok, ${failCount} falharam. Gere as faltantes manualmente pelo Financeiro.`,
life: 6000
}
};
} catch (e) {
return {
toast: {
severity: 'warn',
summary: 'Falha ao materializar série',
detail: e?.message || 'Sessões podem ter sido criadas em parte. Confira no Financeiro.',
life: 6000
}
};
}
}
+28
View File
@@ -0,0 +1,28 @@
/*
|--------------------------------------------------------------------------
| Agência PSI paleta de cores da agenda
|--------------------------------------------------------------------------
| Mapping (tipo, status, isOccurrence) hex color. Usado pelo card do
| FullCalendar (borderColor/backgroundColor) e popovers de evento.
|
| Status manda mais do que tipo: realizado/faltou/cancelado têm cores
| dedicadas (emerald/red/slate) independentes do tipo.
|
| Extraído de useMelissaAgenda.js (Fase A) pra reuso em Rail/Clínica.
|--------------------------------------------------------------------------
*/
export function pickColor(tipo, status, isOccurrence) {
const s = String(status || '').toLowerCase();
if (s === 'realizado' || s === 'realizada') return '#10b981'; // emerald-500
if (s === 'faltou') return '#ef4444'; // red-500
if (s === 'cancelado' || s === 'cancelada') return '#94a3b8'; // slate-400
const t = String(tipo || '').toLowerCase();
if (t === 'bloqueio') return '#64748b'; // slate-500
if (t === 'supervisao' || t === 'supervisão') return '#a855f7'; // purple-500
if (t === 'reuniao' || t === 'reunião') return '#0ea5e9'; // sky-500
// Sessão default — distingue virtual (violet-500) vs real (indigo-500)
return isOccurrence ? '#8b5cf6' : '#6366f1';
}
+34
View File
@@ -0,0 +1,34 @@
/*
|--------------------------------------------------------------------------
| Agência PSI whitelist de campos do agenda_eventos
|--------------------------------------------------------------------------
| Whitelist canônica de campos aceitos na tabela agenda_eventos pra INSERT/
| UPDATE via cliente. Filtra qualquer chave não-prevista (defesa contra bug
| onde payload acidentalmente carrega field defaultado pelo banco como
| modalidade='presencial' do bug de 2026-05-16).
|
| Memoria: project_pickdbfields_whitelist.md antes era inline em
| useMelissaAgenda.js. Extraído na Fase A.
|--------------------------------------------------------------------------
*/
const ALLOWED_FIELDS = [
'tenant_id', 'owner_id', 'terapeuta_id', 'patient_id',
'tipo', 'status', 'titulo', 'observacoes', 'modalidade',
'inicio_em', 'fim_em', 'visibility_scope',
'mirror_of_event_id', 'mirror_source',
'determined_commitment_id', 'titulo_custom', 'extra_fields',
'recurrence_id', 'recurrence_date',
'price', 'insurance_plan_id', 'insurance_guide_number',
'insurance_value', 'insurance_plan_service_id'
];
export function pickDbFields(obj) {
const out = {};
for (const k of ALLOWED_FIELDS) {
if (obj[k] !== undefined) out[k] = obj[k];
}
return out;
}
export { ALLOWED_FIELDS };
+37
View File
@@ -0,0 +1,37 @@
/*
|--------------------------------------------------------------------------
| Agência PSI utils de tipo de evento (agenda)
|--------------------------------------------------------------------------
| Helpers puros pra classificar/normalizar tipo de evento. Extraídos de
| useMelissaAgenda.js (Fase A da decomposição agenda) pra reuso em
| Rail/Clínica + utility puro testável.
|--------------------------------------------------------------------------
*/
export const EVENTO_TIPO = Object.freeze({ SESSAO: 'sessao', BLOQUEIO: 'bloqueio' });
export const EVENTO_TIPOS_VALIDOS = new Set([EVENTO_TIPO.SESSAO, EVENTO_TIPO.BLOQUEIO]);
// Limite máximo de duração de sessão (regra de negócio Melissa). DB constraint
// session_duration_min_chk permite 10240; convencionamos 120 (2h) aqui pra
// evitar slots gigantes acidentais. Futuro: ler de agenda_configuracoes se
// max_session_duration_min for adicionado.
export const MAX_SESSION_MINUTES = 120;
export function normalizeEventoTipo(t, fallback = EVENTO_TIPO.SESSAO) {
const s = String(t || '').trim().toLowerCase();
if (!s) return fallback;
if (s.includes('sess')) return EVENTO_TIPO.SESSAO;
if (s.includes('bloq') || s.includes('ocup')) return EVENTO_TIPO.BLOQUEIO;
return EVENTO_TIPOS_VALIDOS.has(s) ? s : fallback;
}
export function deriveEventoTipoForNewEvent(payload) {
const vis = String(payload?.visibility_scope || '').toLowerCase();
const title = String(payload?.titulo || '').toLowerCase();
if (vis === 'busy_only' || title.includes('ocup')) return EVENTO_TIPO.BLOQUEIO;
return EVENTO_TIPO.SESSAO;
}
export function deriveTituloDefaultByTipo(tipo) {
return normalizeEventoTipo(tipo, EVENTO_TIPO.SESSAO) === EVENTO_TIPO.BLOQUEIO ? 'Ocupado' : 'Sessão';
}
+46
View File
@@ -0,0 +1,46 @@
/*
|--------------------------------------------------------------------------
| Agência PSI utils de tempo/data (agenda)
|--------------------------------------------------------------------------
| Helpers puros pra manipulação de tempo na agenda. Extraídos de
| useMelissaAgenda.js (Fase A) pra reuso em Rail/Clínica.
|--------------------------------------------------------------------------
*/
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function isUuid(v) {
return UUID_RX.test(String(v || ''));
}
/**
* Soma minutos a um time "HH:MM" e retorna "HH:MM:SS".
* Tolerante a input vazio (default 09:00).
*/
export function addMinutesToTime(timeStr, minutes) {
const [h, m] = String(timeStr || '09:00').split(':').map(Number);
const total = h * 60 + m + Number(minutes || 0);
const hh = Math.floor(total / 60);
const mm = total % 60;
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:00`;
}
/**
* ISO timestamp hora decimal (ex: "2026-05-21T14:30:00Z" 14.5).
* Usa hora local (não UTC) propósito de exibição no calendário.
*/
export function isoToDecimalHour(iso) {
if (!iso) return 0;
const d = new Date(iso);
return d.getHours() + d.getMinutes() / 60;
}
/**
* Date object "YYYY-MM-DD" (formato ISO date sem hora).
*/
export function dateToISO(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${dd}`;
}
@@ -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"
@@ -17,6 +17,7 @@ import {
listSignatures,
getSignatureStatus
} from '@/services/DocumentSignatures.service'
import { createShareLink, buildShareUrl } from '@/services/DocumentShareLinks.service'
const props = defineProps({
visible: { type: Boolean, default: false },
@@ -42,6 +43,11 @@ const TIPOS_SIGNATARIO = [
const signatarios = ref([])
const patientEmails = ref([])
// Geracao de share link p/ assinatura via portal/whatsapp
const generateLink = ref(true)
const linkExpiracaoHoras = ref(168) // 7 dias default
const generatedShareUrl = ref('')
function addSignatario() {
signatarios.value.push({ tipo: 'paciente', nome: '', email: '' })
}
@@ -81,6 +87,7 @@ function useEmail(email) {
watch(() => props.visible, async (v) => {
if (v && props.doc) {
signatarios.value = []
generatedShareUrl.value = ''
loading.value = true
try {
const [sigs, status] = await Promise.all([
@@ -99,6 +106,13 @@ watch(() => props.visible, async (v) => {
}
})
function copyShareUrl() {
if (!generatedShareUrl.value) return
navigator.clipboard.writeText(generatedShareUrl.value)
.then(() => toast.add({ severity: 'success', summary: 'Link copiado', life: 1800 }))
.catch(() => toast.add({ severity: 'warn', summary: 'Falha ao copiar', detail: 'Copie manualmente.', life: 2200 }))
}
// Status badge
const statusColor = computed(() => {
@@ -146,9 +160,39 @@ async function submit() {
saving.value = true
try {
const result = await createSignatureRequests(props.doc.id, signatarios.value)
toast.add({ severity: 'success', summary: 'Solicitação enviada', detail: `${result.length} signatário(s) adicionado(s).`, life: 3000 })
emit('requested', result)
emit('update:visible', false)
// Gera share link público quando habilitado o paciente abre /shared/document/:token
// e assina via fluxo público (RPC sign_document_by_token captura IP/UA server-side).
if (generateLink.value) {
try {
const link = await createShareLink(props.doc.id, {
expiracaoHoras: Number(linkExpiracaoHoras.value) || 168,
usosMax: Math.max(signatarios.value.length * 3, 5)
})
generatedShareUrl.value = buildShareUrl(link.token)
toast.add({
severity: 'success',
summary: 'Solicitação criada',
detail: `${result.length} signatário(s). Link de assinatura gerado.`,
life: 3500
})
} catch (linkErr) {
toast.add({
severity: 'warn',
summary: 'Signatários criados, mas falhou o link',
detail: linkErr?.message || 'Tente gerar o link na ação "Compartilhar".',
life: 4500
})
}
} else {
toast.add({ severity: 'success', summary: 'Solicitação enviada', detail: `${result.length} signatário(s) adicionado(s).`, life: 3000 })
}
emit('requested', { signatures: result, shareUrl: generatedShareUrl.value })
// Mantém dialog aberto se gerou link pra terapeuta copiar.
// Fecha automaticamente se não gerou link.
if (!generatedShareUrl.value) emit('update:visible', false)
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao solicitar assinatura.' })
} finally {
@@ -263,6 +307,49 @@ function close() {
</div>
</div>
<!-- Toggle: gerar link público -->
<div class="flex items-start gap-3 p-3 rounded-lg border border-blue-200 bg-blue-50/40">
<Checkbox v-model="generateLink" :binary="true" inputId="cb-gen-link" class="mt-0.5" />
<div class="flex-1">
<label for="cb-gen-link" class="text-sm font-medium text-[var(--text-color)] cursor-pointer">
Gerar link público para assinatura
</label>
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">
Cria um link em <code>/shared/document/&lt;token&gt;</code> pra enviar via WhatsApp, e-mail ou copiar. O paciente assina sem precisar logar (IP, navegador e timestamp são registrados server-side).
</div>
<div v-if="generateLink" class="mt-2 flex items-center gap-2">
<label class="text-xs text-[var(--text-color-secondary)]">Validade:</label>
<Select
v-model="linkExpiracaoHoras"
:options="[
{ value: 24, label: '24 horas' },
{ value: 72, label: '3 dias' },
{ value: 168, label: '7 dias' },
{ value: 720, label: '30 dias' }
]"
optionLabel="label"
optionValue="value"
class="!text-xs w-32"
/>
</div>
</div>
</div>
<!-- Link gerado (após submit) -->
<div v-if="generatedShareUrl" class="p-3 rounded-lg border border-emerald-200 bg-emerald-50/40">
<div class="flex items-center gap-2 mb-2">
<i class="pi pi-link text-emerald-600" />
<div class="text-sm font-medium text-emerald-800">Link de assinatura gerado</div>
</div>
<div class="flex items-center gap-2">
<InputText :modelValue="generatedShareUrl" readonly class="w-full !text-xs" />
<Button icon="pi pi-copy" size="small" class="!h-8 shrink-0" v-tooltip.top="'Copiar link'" @click="copyShareUrl" />
</div>
<div class="text-[0.7rem] text-[var(--text-color-secondary)] mt-1.5">
Envie este link para o(a) paciente. Eles podem assinar diretamente sem precisar criar conta.
</div>
</div>
<!-- Emails cadastrados do paciente -->
<div class="p-3 rounded-lg border border-[var(--surface-border)] bg-[var(--surface-ground)]">
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-2">E-mails do paciente</div>
@@ -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;
@@ -111,7 +121,9 @@ export function useDocumentGenerate() {
patientId,
dadosPreenchidos: { ...variables.value },
pdfBlob: blob,
templateNome
templateNome,
templateTipo: selectedTemplate.value.tipo,
editingDocId
});
generatedDocs.value.unshift(result);
return result;
@@ -0,0 +1,167 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/documents/composables/useDocumentSignatures.js
| Composable Tipo A (thin wrapper) sobre DocumentSignatures.service.
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import {
createSignatureRequests,
listSignatures,
getSignatureStatus,
refuseSignature,
signByPortal,
signByToken,
getSignableDocumentByToken,
listMySignatures,
hashDocument
} from '@/services/DocumentSignatures.service';
export function useDocumentSignatures() {
const signatures = ref([]);
const loading = ref(false);
const error = ref('');
const status = ref(null); // { total, assinados, pendentes, status }
async function fetchForDocument(documentoId) {
if (!documentoId) {
signatures.value = [];
status.value = null;
return [];
}
loading.value = true;
error.value = '';
try {
const [list, st] = await Promise.all([
listSignatures(documentoId),
getSignatureStatus(documentoId)
]);
signatures.value = Array.isArray(list) ? list : [];
status.value = st || null;
return signatures.value;
} catch (e) {
error.value = e?.message || 'Falha ao carregar assinaturas.';
signatures.value = [];
status.value = null;
throw e;
} finally {
loading.value = false;
}
}
async function requestSignatures(documentoId, signatarios = []) {
loading.value = true;
error.value = '';
try {
const rows = await createSignatureRequests(documentoId, signatarios);
signatures.value = [...signatures.value, ...rows];
return rows;
} catch (e) {
error.value = e?.message || 'Falha ao solicitar assinaturas.';
throw e;
} finally {
loading.value = false;
}
}
async function sign(signatureId, { hashDocumento = null } = {}) {
loading.value = true;
error.value = '';
try {
const updated = await signByPortal(signatureId, hashDocumento);
const idx = signatures.value.findIndex(s => s.id === signatureId);
if (idx >= 0) signatures.value.splice(idx, 1, updated);
return updated;
} catch (e) {
error.value = e?.message || 'Falha ao assinar documento.';
throw e;
} finally {
loading.value = false;
}
}
async function refuse(signatureId) {
loading.value = true;
error.value = '';
try {
const updated = await refuseSignature(signatureId);
const idx = signatures.value.findIndex(s => s.id === signatureId);
if (idx >= 0) signatures.value.splice(idx, 1, updated);
return updated;
} catch (e) {
error.value = e?.message || 'Falha ao recusar assinatura.';
throw e;
} finally {
loading.value = false;
}
}
async function signWithToken(token, signatureId = null, { hashDocumento = null } = {}) {
loading.value = true;
error.value = '';
try {
return await signByToken(token, signatureId, hashDocumento);
} catch (e) {
error.value = e?.message || 'Falha ao assinar via link.';
throw e;
} finally {
loading.value = false;
}
}
async function loadMine(statusFilter = null) {
loading.value = true;
error.value = '';
try {
const rows = await listMySignatures(statusFilter);
signatures.value = Array.isArray(rows) ? rows : [];
return signatures.value;
} catch (e) {
error.value = e?.message || 'Falha ao carregar minhas assinaturas.';
signatures.value = [];
throw e;
} finally {
loading.value = false;
}
}
async function loadByToken(token) {
loading.value = true;
error.value = '';
try {
const payload = await getSignableDocumentByToken(token);
if (!payload?.valid) {
error.value = payload?.error === 'expired_or_invalid'
? 'Link expirado ou inválido.'
: payload?.error === 'document_not_found'
? 'Documento não encontrado.'
: 'Token inválido.';
return null;
}
signatures.value = Array.isArray(payload.signatures) ? payload.signatures : [];
return payload;
} catch (e) {
error.value = e?.message || 'Falha ao validar token.';
throw e;
} finally {
loading.value = false;
}
}
return {
signatures,
status,
loading,
error,
fetchForDocument,
requestSignatures,
sign,
refuse,
signWithToken,
loadByToken,
loadMine,
hashDocument
};
}
@@ -32,6 +32,7 @@ import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import DocumentsListPage from '@/features/documents/DocumentsListPage.vue';
import PatientConversationsTab from './PatientConversationsTab.vue';
import { registerPatientVisit } from '@/composables/useRecentPatients';
// PROPS / EMITS
const props = defineProps({
@@ -934,6 +935,8 @@ async function loadDetail(id) {
const [p, rel] = await Promise.all([getPatientById(id), getPatientRelations(id)]);
if (!p) throw new Error('Paciente não retornou dados (RLS bloqueando ou ID não existe no banco).');
patientFull.value = p;
// Registra no "recentemente acessados" (localStorage)
try { await registerPatientVisit(p); } catch { /* ignore */ }
const [g, t] = await Promise.all([
getGroupsByIds(rel.groupIds || []),
getTagsByIds(rel.tagIds || []),
+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
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>
+473 -96
View File
@@ -14,7 +14,9 @@
* 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 } from 'vue';
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useRecentPatients } from '@/composables/useRecentPatients';
const props = defineProps({
pacientes: { type: Array, default: () => [] },
@@ -31,7 +33,7 @@ const props = defineProps({
}
});
const emit = defineEmits(['acao', 'paciente', 'evento']);
const emit = defineEmits(['acao', 'paciente', 'evento', 'documento', 'intake', 'goto-date']);
const rootEl = ref(null);
const inputEl = ref(null);
@@ -39,6 +41,19 @@ const query = ref('');
const showPanel = ref(false);
const activeIndex = ref(-1);
// RPC search_global results preenchidos por debounce conforme o usuário digita.
// Cliente-side (props.pacientes / props.eventos) continua sendo fallback rápido
// pra digitação curta (1 char) e como complemento aos primeiros resultados.
const rpcResults = ref({ patients: [], appointments: [], documents: [], services: [], intakes: [] });
const searching = ref(false);
let debounceT = null;
let searchSeq = 0;
// Recently viewed (localStorage) só aparece quando o input está vazio
const { items: recentPatients, hasItems: hasRecentPatients } = useRecentPatients();
const showRecent = computed(() => !query.value.trim() && hasRecentPatients.value);
const recentItems = computed(() => showRecent.value ? (recentPatients.value || []).slice(0, 5) : []);
function normalize(s) {
return String(s || '')
.normalize('NFD')
@@ -47,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
@@ -62,12 +124,29 @@ const filteredAtalhos = computed(() => {
}).slice(0, 5);
});
// 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 [];
const rpc = rpcResults.value.patients || [];
if (rpc.length) {
return rpc.slice(0, 5).map(p => ({
id: p.id,
nome: p.label || '(sem nome)',
sub: p.sublabel || '',
avatar_url: p.avatar_url || null
}));
}
// 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(() => {
@@ -83,11 +162,23 @@ const filteredEventos = computed(() => {
.slice(0, 5);
});
// Resultados exclusivos da RPC (não há fallback client-side)
const rpcAppointments = computed(() => rpcResults.value.appointments || []);
const rpcDocuments = computed(() => rpcResults.value.documents || []);
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 }));
filteredEventos.value.forEach((e, i) => out.push({ group: 'eventos', item: e, idx: i }));
rpcAppointments.value.forEach((a, i) => out.push({ group: 'rpc-appointments', item: a, idx: i }));
rpcDocuments.value.forEach((d, i) => out.push({ group: 'rpc-documents', item: d, idx: i }));
rpcIntakes.value.forEach((r, i) => out.push({ group: 'rpc-intakes', item: r, idx: i }));
return out;
});
@@ -98,9 +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') {
// 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();
}
@@ -108,75 +212,151 @@ 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();
}
}
// RPC search_global debounced
// Mesmo padrão do GlobalSearch.vue: query >= 2 chars dispara em 200ms,
// controla ordem via searchSeq pra ignorar respostas obsoletas.
function resetRpcResults() {
rpcResults.value = { patients: [], appointments: [], documents: [], services: [], intakes: [] };
}
watch(query, (v) => {
if (debounceT) clearTimeout(debounceT);
const q = String(v || '').trim();
if (q.length < 2) {
resetRpcResults();
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 () => {
try {
const { data, error } = await supabase.rpc('search_global', { p_q: q, p_limit: 6 });
if (mySeq !== searchSeq) return;
if (error) {
console.error('[MelissaBusca search_global]', error);
resetRpcResults();
} else {
rpcResults.value = {
patients: Array.isArray(data?.patients) ? data.patients : [],
appointments: Array.isArray(data?.appointments) ? data.appointments : [],
documents: Array.isArray(data?.documents) ? data.documents : [],
services: Array.isArray(data?.services) ? data.services : [],
intakes: Array.isArray(data?.intakes) ? data.intakes : []
};
}
} catch (e) {
if (mySeq !== searchSeq) return;
console.error('[MelissaBusca search_global exception]', e);
resetRpcResults();
} finally {
if (mySeq === searchSeq) searching.value = false;
}
}, 200);
});
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"
@@ -184,6 +364,45 @@ 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>
<button
v-for="(p, i) in recentItems"
:key="'mr-' + p.id"
class="mb-item"
:class="{ 'is-active': findFlatIndex('recent', i) === activeIndex }"
@click="selectEntry({ group: 'recent', item: p })"
@mouseenter="activeIndex = findFlatIndex('recent', i)"
>
<span class="mb-item__icon"><i class="pi pi-history" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ p.nome }}</span>
<span class="mb-item__sub">{{ p.extras?.telefone || p.extras?.email || 'Abrir prontuário' }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
<!-- Atalhos -->
<div v-if="filteredAtalhos.length" class="mb-group">
<div class="mb-group__title">{{ query.trim() ? 'Ações' : 'Atalhos' }}</div>
@@ -215,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>
@@ -248,8 +467,71 @@ onBeforeUnmount(() => {
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
<!-- 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
v-for="(e, i) in rpcAppointments"
:key="'rpc-a-' + e.id"
class="mb-item"
:class="{ 'is-active': findFlatIndex('rpc-appointments', i) === activeIndex }"
@click="selectEntry({ group: 'rpc-appointments', item: e })"
@mouseenter="activeIndex = findFlatIndex('rpc-appointments', i)"
>
<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.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>
</div>
<!-- RPC: Documentos -->
<div v-if="rpcDocuments.length" class="mb-group">
<div class="mb-group__title">Documentos</div>
<button
v-for="(d, i) in rpcDocuments"
:key="'rpc-d-' + d.id"
class="mb-item"
:class="{ 'is-active': findFlatIndex('rpc-documents', i) === activeIndex }"
@click="selectEntry({ group: 'rpc-documents', item: d })"
@mouseenter="activeIndex = findFlatIndex('rpc-documents', i)"
>
<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.label || 'Documento' }}</span>
<span class="mb-item__sub">{{ d.sublabel || '' }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
<!-- RPC: Cadastros recebidos (intakes) -->
<div v-if="rpcIntakes.length" class="mb-group">
<div class="mb-group__title">Cadastros recebidos</div>
<button
v-for="(r, i) in rpcIntakes"
:key="'rpc-i-' + r.id"
class="mb-item"
:class="{ 'is-active': findFlatIndex('rpc-intakes', i) === activeIndex }"
@click="selectEntry({ group: 'rpc-intakes', item: r })"
@mouseenter="activeIndex = findFlatIndex('rpc-intakes', i)"
>
<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.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>
@@ -258,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%);
@@ -275,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);
@@ -287,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);
@@ -313,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 {
@@ -363,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;
@@ -375,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; }
+21 -10
View File
@@ -40,6 +40,7 @@ const emit = defineEmits([
'delete-series', // botão "Excluir série inteira" hard delete da regra + materializadas + records pendentes
'ver-lancamentos', // botão "Lançamentos" abre dialog com financial_records vinculados
'antecipar-pagamento', // botão "Antecipar pagamento" paciente quer pagar antes da sessão (pacote saldo)
'trocar-metodo-antecipacao', // botão "Trocar método" UPDATE no record paid sem cancel+criar novo (evita lixo cancelled)
'gerar-cobranca', // botão inline "Gerar fatura" ao lado de "A cobrar R$ X" atalho sem precisar abrir dialog
'usar-sessao', // botão "Usar" no card de pacote saldo materializa+realizada+gera cobrança individual
'revogar-sessao' // botão "Revogar" desfaz Usar (cancela record + decrementa saldo). Bloqueado se já pago
@@ -507,16 +508,26 @@ function modalidadeIcon(mod) {
<i class="pi pi-money-bill" />
<span class="evento-act__label">Antecipar pagamento</span>
</button>
<button
v-else
class="evento-act evento-act--danger"
:disabled="busy"
v-tooltip.top="'Desfazer o pagamento antecipado — cancela o lançamento e libera pra antecipar de novo'"
@click="emit('revogar-antecipacao')"
>
<i class="pi pi-times-circle" />
<span class="evento-act__label">Revogar pagamento</span>
</button>
<template v-else>
<button
class="evento-act"
:disabled="busy"
v-tooltip.top="'Atualizar a forma de pagamento sem cancelar o registro (não gera lixo no histórico)'"
@click="emit('trocar-metodo-antecipacao')"
>
<i class="pi pi-sync" />
<span class="evento-act__label">Trocar método</span>
</button>
<button
class="evento-act evento-act--danger"
:disabled="busy"
v-tooltip.top="'Desfazer o pagamento antecipado — cancela o lançamento'"
@click="emit('revogar-antecipacao')"
>
<i class="pi pi-times-circle" />
<span class="evento-act__label">Revogar pagamento</span>
</button>
</template>
</div>
</section>
+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
+544 -103
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;
@@ -751,6 +824,17 @@ const lancamentosDialogOpen = ref(false);
const lancamentosList = ref([]);
const lancamentosLoading = ref(false);
const lancamentosEventoTitulo = ref('');
// Por default esconde cancelled (poluem o que importa quando user só
// quer ver os ativos). Toggle 'Mostrar histórico' libera audit trail.
// Resetado a cada abertura do dialog (onVerLancamentos).
const lancamentosShowHistory = ref(false);
const lancamentosFiltered = computed(() => {
if (lancamentosShowHistory.value) return lancamentosList.value;
return lancamentosList.value.filter((r) => r.status !== 'cancelled');
});
const lancamentosCancelledCount = computed(() =>
lancamentosList.value.filter((r) => r.status === 'cancelled').length
);
// Antecipar pagamento (Fase 5, 2026-05-14): paciente quer pagar antes da
// sessão (caso típico em pacote saldo). Materializa a ocorrência (se virtual)
// + cria financial_record paid (PIX/etc) ou pending (Asaas). NÃO decrementa
@@ -759,6 +843,9 @@ const anteciparDialogOpen = ref(false);
const anteciparMethod = ref('pix');
const anteciparBusy = ref(false);
const anteciparEventoRef = ref(null); // snapshot do evento no momento do click
// mode: 'create' (Antecipar pagamento novo record) vs 'update' (Trocar método
// UPDATE no record paid existente, sem cancel+criar lixo de cancelled).
const anteciparMode = ref('create');
const anteciparMethodOptions = [
{ value: 'pix', label: 'Já recebi — PIX' },
{ value: 'dinheiro', label: 'Já recebi — Dinheiro' },
@@ -782,6 +869,39 @@ async function onAnteciparPagamento() {
}
anteciparEventoRef.value = ev;
anteciparMethod.value = 'pix';
anteciparMode.value = 'create';
anteciparDialogOpen.value = true;
}
// Trocar método de pagamento de uma antecipação ATIVA (record paid existente).
// Mesmo dialog do Antecipar, mas no submit faz UPDATE no record existente
// em vez de cancel+criar novo evita acumular records cancelled no audit
// trail. Default seleciona o método atual pra UX clara.
async function onTrocarMetodoAntecipacao() {
const ev = eventoSelecionado.value;
if (!ev?.id) return;
const isVirtualId = typeof ev.id === 'string' && ev.id.startsWith('rec::');
if (isVirtualId) {
toast.add({ severity: 'warn', summary: 'Sessão virtual', detail: 'Sessão sem antecipação ativa.', life: 3000 });
return;
}
// Busca método atual do record paid pra pré-selecionar no dialog
try {
const { data: paidRec } = await supabase
.from('financial_records')
.select('payment_method')
.eq('agenda_evento_id', ev.id)
.eq('status', 'paid')
.order('paid_at', { ascending: false })
.limit(1)
.maybeSingle();
const current = paidRec?.payment_method;
anteciparMethod.value = ['pix', 'dinheiro', 'deposito', 'cartao_maquininha'].includes(current) ? current : 'pix';
} catch {
anteciparMethod.value = 'pix';
}
anteciparEventoRef.value = ev;
anteciparMode.value = 'update';
anteciparDialogOpen.value = true;
}
@@ -790,6 +910,53 @@ async function confirmAnteciparPagamento() {
if (!ev || anteciparBusy.value) return;
anteciparBusy.value = true;
try {
// MODE 'update': Trocar método
// Apenas UPDATE no record paid existente. Sem materializar (já é real),
// sem RPC, sem novo record. Evita lixo cancelled no audit trail.
if (anteciparMode.value === 'update') {
const settlement = anteciparMethod.value;
const today = new Date().toISOString();
const { data: paidRec, error: fetchErr } = await supabase
.from('financial_records')
.select('id, payment_method, notes')
.eq('agenda_evento_id', ev.id)
.eq('status', 'paid')
.order('paid_at', { ascending: false })
.limit(1)
.maybeSingle();
if (fetchErr) throw fetchErr;
if (!paidRec?.id) {
throw new Error('Antecipação não encontrada para troca de método.');
}
const oldMethod = paidRec.payment_method || '—';
const noteEntry = `[${today.slice(0, 10)}] Método trocado: ${oldMethod}${settlement}`;
const newNotes = paidRec.notes ? `${paidRec.notes}\n${noteEntry}` : noteEntry;
const patch = {
payment_method: settlement === 'link' ? 'asaas' : settlement,
status: settlement === 'link' ? 'pending' : 'paid',
paid_at: settlement === 'link' ? null : today,
notes: newNotes,
updated_at: today
};
const { error: upErr } = await supabase
.from('financial_records')
.update(patch)
.eq('id', paidRec.id);
if (upErr) throw upErr;
const methodLabel = anteciparMethodOptions.find((o) => o.value === settlement)?.label || settlement;
toast.add({
severity: 'success',
summary: 'Método atualizado',
detail: methodLabel,
life: 3500
});
anteciparDialogOpen.value = false;
await M.refetch();
refetchEventosHoje();
return;
}
// MODE 'create' (Antecipar pagamento fluxo original)
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
// ownerId: ev.owner_id é prioridade. Fallback pra M.ownerId (composable
// que conhece o user logado). Pra virtuais ou snapshots stale, ev pode
@@ -1005,6 +1172,7 @@ async function onVerLancamentos() {
const isVirtual = ev.is_occurrence || isVirtualId;
lancamentosEventoTitulo.value = ev.pacienteNome || ev.label || ev.titulo || 'Sessão';
lancamentosShowHistory.value = false; // sempre abre limpo (sem cancelled)
lancamentosDialogOpen.value = true;
lancamentosLoading.value = true;
try {
@@ -1650,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) {
@@ -1892,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();
}
@@ -2252,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 -->
@@ -2333,12 +2476,16 @@ function onKeydown(e) {
<!-- Busca rápida (entre o "Hoje há" e a timeline) -->
<MelissaBusca
ref="melissaBuscaRef"
class="mt-8"
:pacientes="pacientesReais"
:eventos="eventosHojeReais"
@acao="abrirSecao"
@paciente="() => abrirSecao('pacientes')"
@paciente="(p) => p?.id ? router.push({ path: '/melissa/paciente', query: { id: String(p.id) } }) : abrirSecao('pacientes')"
@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) -->
@@ -2351,6 +2498,7 @@ function onKeydown(e) {
:filtro-tipo="filtroTipo"
@evento="abrirEvento"
@clear-filter="limparFiltro"
@iniciar-cronometro="onIniciarCronometroFromEvento"
/>
<!-- Cards (catálogo + ativos + layout switchável) -->
@@ -2363,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
@@ -2516,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
@@ -2535,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"
@@ -2549,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"
/>
@@ -2574,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"
@@ -2627,6 +2959,7 @@ function onKeydown(e) {
@revogar-sessao="onRevogarSessao"
@ver-lancamentos="onVerLancamentos"
@antecipar-pagamento="onAnteciparPagamento"
@trocar-metodo-antecipacao="onTrocarMetodoAntecipacao"
@revogar-antecipacao="onRevogarAntecipacao"
@edit-paciente="onEditPaciente"
@abrir-prontuario="onAbrirProntuario"
@@ -3135,12 +3468,29 @@ function onKeydown(e) {
<div v-else-if="!lancamentosList.length" class="py-6 text-center text-sm opacity-70">
<i class="pi pi-info-circle mr-1" /> Nenhum lançamento vinculado a esta sessão.
</div>
<div v-else-if="!lancamentosFiltered.length" class="py-6 text-center text-sm opacity-70">
<i class="pi pi-info-circle mr-1" /> Sem lançamentos ativos. {{ lancamentosCancelledCount }} cancelado(s) no histórico.
<div class="mt-3">
<Button :label="`Mostrar ${lancamentosCancelledCount} cancelado(s)`" size="small" text @click="lancamentosShowHistory = true" />
</div>
</div>
<div v-else class="flex flex-col gap-2.5">
<!-- Toggle de histórico ( aparece quando cancelled) -->
<div v-if="lancamentosCancelledCount > 0" class="flex items-center justify-end gap-2 text-xs opacity-70">
<span v-if="!lancamentosShowHistory">{{ lancamentosCancelledCount }} cancelado(s) ocultos.</span>
<Button
:label="lancamentosShowHistory ? 'Ocultar histórico' : 'Mostrar histórico'"
:icon="lancamentosShowHistory ? 'pi pi-eye-slash' : 'pi pi-history'"
size="small"
text
@click="lancamentosShowHistory = !lancamentosShowHistory"
/>
</div>
<div
v-for="(r, idx) in lancamentosList"
v-for="(r, idx) in lancamentosFiltered"
:key="r.id"
class="ml-lanc-card"
:class="{ 'ml-lanc-card--child': idx > 0 }"
:class="{ 'ml-lanc-card--child': idx > 0, 'ml-lanc-card--cancelled': r.status === 'cancelled' }"
>
<div class="ml-lanc-card__head">
<i v-if="idx > 0" class="pi pi-arrow-right-and-arrow-left-up-down ml-lanc-card__indent" />
@@ -3179,19 +3529,24 @@ function onKeydown(e) {
v-model:visible="anteciparDialogOpen"
modal
:draggable="false"
header="Antecipar pagamento"
:header="anteciparMode === 'update' ? 'Trocar método de pagamento' : 'Antecipar pagamento'"
:style="{ width: '480px', maxWidth: '96vw' }"
>
<div class="flex flex-col gap-3 pt-1">
<div class="text-sm">
Receba antecipadamente o valor desta sessão.
<template v-if="anteciparMode === 'update'">
Atualizar a forma de pagamento sem cancelar o registro atual (mais limpo no histórico).
</template>
<template v-else>
Receba antecipadamente o valor desta sessão.
</template>
</div>
<div v-if="anteciparEventoRef" class="flex flex-col gap-1 px-3 py-2 rounded-md bg-[var(--surface-section)] border border-[var(--surface-border)]">
<div class="text-sm font-semibold">{{ anteciparEventoRef.pacienteNome || 'Sessão' }}</div>
<div class="text-xs opacity-70">Valor: R$ {{ Number(anteciparEventoRef.price || 0).toFixed(2).replace('.', ',') }}</div>
</div>
<div class="flex flex-col gap-1.5">
<label class="text-xs font-medium">Como o paciente pagou?</label>
<label class="text-xs font-medium">{{ anteciparMode === 'update' ? 'Novo método de pagamento' : 'Como o paciente pagou?' }}</label>
<Select
v-model="anteciparMethod"
:options="anteciparMethodOptions"
@@ -3200,13 +3555,16 @@ function onKeydown(e) {
size="small"
/>
</div>
<small class="text-xs opacity-60">
<small v-if="anteciparMode !== 'update'" class="text-xs opacity-60">
O saldo do pacote será decrementado quando você marcar a sessão como Realizada.
</small>
<small v-else class="text-xs opacity-60">
A troca é registrada no histórico do lançamento (auditoria), sem criar novo registro.
</small>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined :disabled="anteciparBusy" @click="anteciparDialogOpen = false" />
<Button label="Confirmar" icon="pi pi-check" :loading="anteciparBusy" @click="confirmAnteciparPagamento" />
<Button :label="anteciparMode === 'update' ? 'Atualizar' : 'Confirmar'" icon="pi pi-check" :loading="anteciparBusy" @click="confirmAnteciparPagamento" />
</template>
</Dialog>
@@ -3733,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) */
@@ -3759,6 +4117,18 @@ function onKeydown(e) {
margin-left: 1.5rem;
border-color: color-mix(in srgb, var(--p-primary-color) 25%, var(--surface-border));
}
/* Cancelled apenas visíveis quando user expande o histórico.
Visual atenuado pra sinalizar "audit trail, não-ativo". */
.ml-lanc-card--cancelled {
opacity: 0.55;
border-style: dashed;
background: color-mix(in srgb, var(--surface-ground) 60%, transparent);
}
.ml-lanc-card--cancelled .ml-lanc-card__desc {
text-decoration: line-through;
text-decoration-color: currentColor;
text-decoration-thickness: 1px;
}
.ml-lanc-card__head {
display: flex;
align-items: center;
@@ -3876,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
@@ -4030,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
+14 -11
View File
@@ -24,10 +24,11 @@ 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';
import { registerPatientVisit } from '@/composables/useRecentPatients';
import { usePatientSessions } from '@/features/patients/composables/usePatientSessions';
import { usePatientFinancial } from '@/features/patients/composables/usePatientFinancial';
import { usePatientMessages } from '@/features/patients/composables/usePatientMessages';
@@ -617,6 +618,13 @@ async function loadAll(id) {
documentsHook.load(id),
recorrenciasHook.load(id)
]);
// Registra visita no histórico "recentemente acessados" (localStorage).
// Fora do Promise.all pra não bloquear renderização.
const p = detail.patient?.value;
if (p?.id) {
try { await registerPatientVisit(p); } catch { /* ignore */ }
}
}
watch(() => props.patientId, async (id) => {
@@ -2084,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>
+87
View File
@@ -12,12 +12,15 @@
* isoWeek/isoMonth + Chart.js).
*/
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { exportSessionsToPDF, exportSessionsToXLSX, exportSessionsToCSV } from '@/services/reportExport.service';
// Chart/DataTable/Column/Tag/Skeleton: auto via PrimeVueResolver
const emit = defineEmits(['close']);
const tenantStore = useTenantStore();
const toast = useToast();
// Breakpoints + drawer mobile
const drawerOpen = ref(false);
@@ -251,6 +254,66 @@ function patientName(s) {
return s.patients?.nome_completo || '—';
}
// Export PDF / Excel / CSV
const exportingPdf = ref(false);
const exportingXlsx = ref(false);
function buildExportParams() {
const period = PERIOD_OPTIONS.find(p => p.key === selectedPeriod.value)?.label || '';
const normalized = sessionsFiltradas.value.map(s => ({
...s,
paciente_nome: s.patients?.nome_completo || '—'
}));
return {
title: 'Relatório de Sessões',
subtitle: period,
sessions: normalized,
kpis: [
{ label: 'Total', value: total.value },
{ label: 'Realizadas', value: realizadas.value },
{ label: 'Faltas', value: faltas.value },
{ label: 'Canceladas', value: canceladas.value }
],
tenantName: tenantStore.activeTenantName || tenantStore.tenant?.name || '',
terapeutaNome: tenantStore.user?.full_name || tenantStore.user?.email || ''
};
}
async function exportPdf() {
if (exportingPdf.value) return;
exportingPdf.value = true;
try {
const file = await exportSessionsToPDF(buildExportParams());
toast.add({ severity: 'success', summary: 'PDF gerado', detail: file, life: 2500 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao gerar PDF', detail: e?.message || '', life: 4500 });
} finally {
exportingPdf.value = false;
}
}
async function exportXlsx() {
if (exportingXlsx.value) return;
exportingXlsx.value = true;
try {
const file = await exportSessionsToXLSX(buildExportParams());
toast.add({ severity: 'success', summary: 'Excel gerado', detail: file, life: 2500 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao gerar Excel', detail: e?.message || '', life: 4500 });
} finally {
exportingXlsx.value = false;
}
}
function exportCsv() {
try {
const file = exportSessionsToCSV(buildExportParams());
toast.add({ severity: 'success', summary: 'CSV gerado', detail: file, life: 2500 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao gerar CSV', detail: e?.message || '', life: 4500 });
}
}
watch(selectedPeriod, () => {
statusFilter.value = null;
loadSessions();
@@ -308,6 +371,30 @@ onBeforeUnmount(() => {
<span class="mr-page__count">{{ periodLabel }}</span>
</div>
<div class="mr-page__actions">
<button
class="mr-head-btn"
v-tooltip.bottom="'Exportar PDF'"
:disabled="exportingPdf || loading || total === 0"
@click="exportPdf"
>
<i :class="exportingPdf ? 'pi pi-spin pi-spinner' : 'pi pi-file-pdf'" />
</button>
<button
class="mr-head-btn"
v-tooltip.bottom="'Exportar Excel (.xlsx)'"
:disabled="exportingXlsx || loading || total === 0"
@click="exportXlsx"
>
<i :class="exportingXlsx ? 'pi pi-spin pi-spinner' : 'pi pi-file-excel'" />
</button>
<button
class="mr-head-btn"
v-tooltip.bottom="'Exportar CSV'"
:disabled="loading || total === 0"
@click="exportCsv"
>
<i class="pi pi-table" />
</button>
<button
class="mr-head-btn"
v-tooltip.bottom="'Recarregar'"
+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',
@@ -38,84 +38,30 @@ import { useCommitmentServices } from '@/features/agenda/composables/useCommitme
import { useFeriados } from '@/composables/useFeriados';
import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios';
// ─── Constantes do domínio (espelhadas de AgendaTerapeutaPage) ──────────────
const EVENTO_TIPO = Object.freeze({ SESSAO: 'sessao', BLOQUEIO: 'bloqueio' });
const EVENTO_TIPOS_VALIDOS = new Set([EVENTO_TIPO.SESSAO, EVENTO_TIPO.BLOQUEIO]);
// ─── Utilities puros (extraídos na Fase A da decomposição agenda) ───────────
// Mantidos em features/agenda/utils/ pra reuso em Rail/Clínica.
import {
EVENTO_TIPO,
EVENTO_TIPOS_VALIDOS,
MAX_SESSION_MINUTES,
normalizeEventoTipo,
deriveEventoTipoForNewEvent,
deriveTituloDefaultByTipo
} from '@/features/agenda/utils/eventoTipo';
import { pickDbFields } from '@/features/agenda/utils/dbFields';
import { isUuid, addMinutesToTime as _addMinutesToTime, isoToDecimalHour, dateToISO as _dateToISO } from '@/features/agenda/utils/timeHelpers';
import { pickColor } from '@/features/agenda/utils/colors';
// Limite máximo de duração de sessão (regra de negócio Melissa). DB constraint
// `session_duration_min_chk` permite 10240; convencionamos 120 (2h) aqui pra
// evitar slots gigantes acidentais. Futuro: ler de `agenda_configuracoes` se
// `max_session_duration_min` for adicionado.
const MAX_SESSION_MINUTES = 120;
function isUuid(v) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(v || ''));
}
function normalizeEventoTipo(t, fallback = EVENTO_TIPO.SESSAO) {
const s = String(t || '').trim().toLowerCase();
if (!s) return fallback;
if (s.includes('sess')) return EVENTO_TIPO.SESSAO;
if (s.includes('bloq') || s.includes('ocup')) return EVENTO_TIPO.BLOQUEIO;
return EVENTO_TIPOS_VALIDOS.has(s) ? s : fallback;
}
function deriveEventoTipoForNewEvent(payload) {
const vis = String(payload?.visibility_scope || '').toLowerCase();
const title = String(payload?.titulo || '').toLowerCase();
if (vis === 'busy_only' || title.includes('ocup')) return EVENTO_TIPO.BLOQUEIO;
return EVENTO_TIPO.SESSAO;
}
function deriveTituloDefaultByTipo(tipo) {
return normalizeEventoTipo(tipo, EVENTO_TIPO.SESSAO) === EVENTO_TIPO.BLOQUEIO ? 'Ocupado' : 'Sessão';
}
function pickDbFields(obj) {
const allowed = [
'tenant_id', 'owner_id', 'terapeuta_id', 'patient_id',
'tipo', 'status', 'titulo', 'observacoes', 'modalidade',
'inicio_em', 'fim_em', 'visibility_scope',
'mirror_of_event_id', 'mirror_source',
'determined_commitment_id', 'titulo_custom', 'extra_fields',
'recurrence_id', 'recurrence_date',
'price', 'insurance_plan_id', 'insurance_guide_number',
'insurance_value', 'insurance_plan_service_id'
];
const out = {};
for (const k of allowed) {
if (obj[k] !== undefined) out[k] = obj[k];
}
return out;
}
function _addMinutesToTime(timeStr, minutes) {
const [h, m] = String(timeStr || '09:00').split(':').map(Number);
const total = h * 60 + m + Number(minutes || 0);
const hh = Math.floor(total / 60);
const mm = total % 60;
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:00`;
}
// ─── Melissa-style normalize (color, label, startH/endH, dateKey, _raw) ─────
function pickColor(tipo, status, isOccurrence) {
const s = String(status || '').toLowerCase();
if (s === 'realizado' || s === 'realizada') return '#10b981';
if (s === 'faltou') return '#ef4444';
if (s === 'cancelado' || s === 'cancelada') return '#94a3b8';
const t = String(tipo || '').toLowerCase();
if (t === 'bloqueio') return '#64748b';
if (t === 'supervisao' || t === 'supervisão') return '#a855f7';
if (t === 'reuniao' || t === 'reunião') return '#0ea5e9';
return isOccurrence ? '#8b5cf6' : '#6366f1'; // virtual: violet, real: indigo
}
function isoToDecimalHour(iso) {
if (!iso) return 0;
const d = new Date(iso);
return d.getHours() + d.getMinutes() / 60;
}
// ─── Service de billing (Fase B1 read-only + Fase B2 mutations) ────────────
import {
computeSeriePrice as _computeSeriePrice,
generateOccurrenceDates as _generateOccurrenceDates,
loadStatusChangeContext,
needsStatusConfirmDialog,
applyStatusDecisions,
createPackageContract as _createPackageContractService,
materializeAndChargePerSession as _materializeAndChargePerSessionService
} from '@/features/agenda/services/agendaBilling.service';
function normalizeForMelissa(r, paymentStateMap = null, paymentAmountMap = null, rulePaymentMap = null, ruleContractMap = null) {
// r pode vir de useAgendaEvents (linha real do banco) ou de loadAndExpand
@@ -1332,520 +1278,41 @@ function _buildHandlers(deps) {
}
// Carrega contexto pra decidir se mostra dialog e quais blocos renderizar.
// Wrapper fino sobre o service (Fase B1) — injeta supabase, ownerId, tenantId
// do escopo do composable. Lógica pura mora em agendaBilling.service.
async function _loadStatusChangeContext({ row, eventoId, status }) {
const ctx = { regraExcecao: null, billingContract: null, pendingRecord: null };
// 1) Regra de exceção (faltou → patient_no_show, cancelado → patient_cancellation)
const exceptionTypeMap = { faltou: 'patient_no_show', cancelado: 'patient_cancellation' };
const excType = exceptionTypeMap[status];
if (excType && clinicTenantId.value) {
try {
const { data } = await supabase
.from('financial_exceptions')
.select('*')
.eq('tenant_id', clinicTenantId.value)
.eq('exception_type', excType)
.or(`owner_id.eq.${ownerId.value},owner_id.is.null`)
.order('owner_id', { ascending: false, nullsLast: true })
.limit(1)
.maybeSingle();
ctx.regraExcecao = data ?? null;
} catch (e) {
console.warn('[Fase5] erro carregando regra de exceção:', e?.message);
}
}
// 2) Billing contract — tenta 3 caminhos:
// (a) row.billing_contract_id direto (sessão real materializada)
// (b) eventoId real → query agenda_eventos.billing_contract_id
// (c) ocorrência virtual (sem id real) → busca contrato ativo do paciente
const patientId = row.patient_id ?? row.paciente_id ?? null;
const contractId = row.billing_contract_id ?? null;
if (contractId) {
try {
const { data } = await supabase.from('billing_contracts').select('*').eq('id', contractId).maybeSingle();
ctx.billingContract = data ?? null;
} catch (e) {
console.warn('[Fase5] erro contract via id direto:', e?.message);
}
}
if (!ctx.billingContract && eventoId) {
// Sessão real materializada — pode ter billing_contract_id no DB mesmo
// que a row passada não tenha (caso de virtual recém-materializada).
try {
const { data: ev } = await supabase.from('agenda_eventos').select('billing_contract_id').eq('id', eventoId).maybeSingle();
if (ev?.billing_contract_id) {
const { data: c } = await supabase.from('billing_contracts').select('*').eq('id', ev.billing_contract_id).maybeSingle();
ctx.billingContract = c ?? null;
}
} catch (e) {
console.warn('[Fase5] erro contract via agenda_evento:', e?.message);
}
}
if (!ctx.billingContract && patientId && clinicTenantId.value) {
// Ocorrência virtual da Anna Freud cai aqui: busca contrato ativo
// do paciente. MVP assume 1 contrato active por paciente; pega o
// mais recente caso haja mais de um.
try {
const { data: c } = await supabase
.from('billing_contracts')
.select('*')
.eq('tenant_id', clinicTenantId.value)
.eq('patient_id', patientId)
.eq('status', 'active')
.eq('type', 'package')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
ctx.billingContract = c ?? null;
} catch (e) {
console.warn('[Fase5] erro contract via patient_id:', e?.message);
}
}
// 3) Pending record (se evento já existe e tem cobrança pendente)
if (eventoId) {
try {
const { data } = await supabase
.from('financial_records')
.select('*')
.eq('agenda_evento_id', eventoId)
.in('status', ['pending', 'overdue'])
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
ctx.pendingRecord = data ?? null;
} catch (e) {
console.warn('[Fase5] erro pending record:', e?.message);
}
}
// 3b) Paid record pré-existente (caso C12: antecipar pagamento).
// Quando user antecipou paga ANTES de marcar Realizada, o record paid
// já existe ao tempo do status change. Dialog precisa saber pra:
// - Não oferecer "Gerar cobrança nova" (geraria duplicidade)
// - Ainda incrementar sessions_used (a sessão consome saldo do pacote)
if (eventoId) {
try {
const { data } = await supabase
.from('financial_records')
.select('id, status, amount, final_amount, paid_at, payment_method')
.eq('agenda_evento_id', eventoId)
.eq('status', 'paid')
.order('paid_at', { ascending: false })
.limit(1)
.maybeSingle();
ctx.existingPaidRecord = data ?? null;
} catch (e) {
console.warn('[Fase5] erro existing paid record:', e?.message);
}
}
// 4) Reverse transition (status novo='agendado'): carrega artefatos
// a desfazer — current status + ALL records ativos + saldo consumido.
// Sem isso, voltar pra agendado deixa multa/record/saldo órfão.
if (status === 'agendado' && eventoId) {
ctx.reverseArtifacts = {
previousStatus: row?.status || null,
activeRecords: [],
saldoConsumed: false
};
try {
// Status atual do DB (fonte autoritativa, row pode estar stale)
const { data: evRow } = await supabase
.from('agenda_eventos')
.select('status, billing_contract_id')
.eq('id', eventoId)
.maybeSingle();
if (evRow) {
ctx.reverseArtifacts.previousStatus = evRow.status;
}
// Todos records NÃO cancelled vinculados (pending + overdue + paid)
const { data: recs } = await supabase
.from('financial_records')
.select('id, status, amount, final_amount, description, paid_at, payment_method')
.eq('agenda_evento_id', eventoId)
.neq('status', 'cancelled')
.order('created_at', { ascending: false });
ctx.reverseArtifacts.activeRecords = recs || [];
// Detecta saldo consumido: evento pertence a pacote saldo e
// está em status que tipicamente consome (realizado, ou faltou/
// cancelado se default_consume_on_miss=true e foi aplicado).
// Heurística simples: se billing_contract_id está set + style=saldo
// + status anterior ≠ 'agendado', assume consumido. Se for falso
// positivo, user pode escolher "não devolver" no dialog.
const wasInActiveState = evRow?.billing_contract_id && evRow.status && evRow.status !== 'agendado';
ctx.reverseArtifacts.saldoConsumed = wasInActiveState && ctx.billingContract?.charging_style === 'saldo';
} catch (e) {
console.warn('[Fase5] erro reverse artifacts:', e?.message);
}
}
return ctx;
return loadStatusChangeContext({
supabase,
row,
eventoId,
status,
ownerId: ownerId.value,
tenantId: clinicTenantId.value
});
}
// Precisa dialog? Sim se há regra de exceção com charge_mode != 'none'
// OU pacote saldo OU pacote upfront OU pending record (realizado).
function _needsConfirmDialog(status, ctx) {
const isFaltouOrCancel = status === 'faltou' || status === 'cancelado';
const isRealizado = status === 'realizado';
const isAgendado = status === 'agendado';
const hasRegraComCobranca = ctx.regraExcecao && ctx.regraExcecao.charge_mode !== 'none';
const isPacoteSaldo = ctx.billingContract?.charging_style === 'saldo';
const isPacoteUpfront = ctx.billingContract?.charging_style === 'upfront';
const hasPending = !!ctx.pendingRecord;
if (isFaltouOrCancel) {
// Mostra se há regra ou se é pacote saldo (pra perguntar consume)
return hasRegraComCobranca || isPacoteSaldo || isPacoteUpfront;
}
if (isRealizado) {
// Mostra se há pending (avulsa) ou pacote saldo (cobrança nova)
return hasPending || isPacoteSaldo;
}
if (isAgendado) {
// Reverse transition: mostra se há artefatos a desfazer
const r = ctx.reverseArtifacts;
if (!r) return false;
const hasActiveRecords = (r.activeRecords?.length || 0) > 0;
return hasActiveRecords || r.saldoConsumed;
}
return false;
}
// _needsConfirmDialog (Fase B1): alias local pra needsStatusConfirmDialog
// do agendaBilling.service. Pure — sem deps de composable state.
const _needsConfirmDialog = needsStatusConfirmDialog;
// Aplica as decisões do dialog (saldo, multa, paid, cobrança pacote, reverse).
// _applyStatusDecisions agora é wrapper fino sobre applyStatusDecisions do
// service (Fase B2). Injeta supabase + toast + ownerId + tenantId do escopo.
async function _applyStatusDecisions({ eventoId, row, novoStatus, ctx, decision }) {
const tenantId = clinicTenantId.value;
const uid = ownerId.value;
const patientId = row.patient_id ?? row.paciente_id ?? null;
const tasks = [];
// ─── REVERSE TRANSITION (novoStatus='agendado' com artefatos a desfazer) ──
// Tratado antes dos blocos forward porque a lógica é distinta —
// cancelar records, devolver saldo, sem multa nova. Status já foi
// atualizado pelo _applyStatusUpdateOnly antes desta função.
if (novoStatus === 'agendado' && ctx.reverseArtifacts) {
const r = ctx.reverseArtifacts;
// 1) Cancelar records pending/overdue (se decidiu)
if (decision.reverseCancelPending && (r.activeRecords?.length || 0) > 0) {
const pendingIds = r.activeRecords.filter((rec) => rec.status === 'pending' || rec.status === 'overdue').map((rec) => rec.id);
if (pendingIds.length > 0) {
try {
const today = new Date().toISOString().slice(0, 10);
const reason = `Cancelada via reversão de status (${r.previousStatus} → agendado) em ${today}`;
// Cancela um por um pra capturar erro individual; alternativa
// seria UPDATE em batch com IN, mas notes precisa preservar
// o que tinha antes per-row. Aqui priorizamos clareza.
for (const id of pendingIds) {
const { error: cErr } = await supabase
.from('financial_records')
.update({
status: 'cancelled',
notes: `[${today}] ${reason}`,
updated_at: new Date().toISOString()
})
.eq('id', id);
if (cErr) throw cErr;
}
} catch (e) {
console.error('[Fase5/reverse] erro cancelando records:', e?.message);
toast.add({ severity: 'warn', summary: 'Cobrança', detail: e?.message || 'Falha ao cancelar cobranças', life: 5000 });
}
}
}
// 2) Devolver saldo ao pacote (se decidiu)
// Refetch sessions_used FRESH antes de decrementar pra evitar
// race condition com flows que rodaram entre _loadStatusChangeContext
// e este ponto (ex: Realizada+gerar imediatamente seguido de Agendada).
if (decision.reverseRestoreSaldo && r.saldoConsumed && ctx.billingContract?.id) {
try {
const { data: freshContract, error: fetchErr } = await supabase
.from('billing_contracts')
.select('sessions_used, total_sessions, status')
.eq('id', ctx.billingContract.id)
.maybeSingle();
if (fetchErr) throw fetchErr;
const currentUsed = freshContract?.sessions_used ?? 0;
const totalSessions = freshContract?.total_sessions ?? 0;
const newUsed = Math.max(0, currentUsed - 1);
const patch = { sessions_used: newUsed };
// Se contrato estava 'completed' (atingiu total) e voltou abaixo, reativa.
if (currentUsed >= totalSessions) {
patch.status = 'active';
}
console.log('[Fase5/reverse] decrementando saldo:', { from: currentUsed, to: newUsed, contractId: ctx.billingContract.id });
const { error: dErr } = await supabase.from('billing_contracts').update(patch).eq('id', ctx.billingContract.id);
if (dErr) throw dErr;
} catch (e) {
console.error('[Fase5/reverse] erro decrementando saldo:', e?.message);
toast.add({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao devolver saldo', life: 5000 });
}
}
// 3) Desamarrar billing_contract_id do evento (evento agora está
// agendado, conceitualmente sem vínculo ativo até user reusar).
// Só desamarrar se devolveu saldo — se manteve consumido,
// deixa o vínculo pra rastreabilidade.
if (decision.reverseRestoreSaldo && r.saldoConsumed) {
try {
await supabase.from('agenda_eventos').update({ billing_contract_id: null, updated_at: new Date().toISOString() }).eq('id', eventoId);
} catch (e) {
console.warn('[Fase5/reverse] erro desamarrando billing_contract_id:', e?.message);
}
}
toast.add({ severity: 'success', summary: 'Sessão reativada', detail: 'Status revertido pra agendada.', life: 2500 });
return; // pula blocos forward
}
// 1) Consumir saldo (pacote saldo + faltou/cancelado + decisão sim)
// ⚠ billing_contracts NÃO tem coluna updated_at — passar esse campo
// causa "column does not exist" silenciosamente em Promise.allSettled.
// Amarração de billing_contract_id no evento é feita em 1b) universal.
if (decision.consumeSaldo && ctx.billingContract?.id) {
tasks.push(
supabase
.from('billing_contracts')
.update({
sessions_used: (ctx.billingContract.sessions_used ?? 0) + 1
})
.eq('id', ctx.billingContract.id)
);
}
// 1b) Amarra evento ao contrato — universal pra forward em pacote.
// Antes só rodava em consumeSaldo / generatePackageCharge. Faltou+multa
// SEM consume era exceção: evento ficava sem billing_contract_id,
// impedindo o reverse de detectar o vínculo depois. Fix: amarrar
// sempre que há contract envolvido + status forward + eventoId real.
const isForwardStatus = novoStatus === 'realizado' || novoStatus === 'faltou' || novoStatus === 'cancelado';
if (isForwardStatus && ctx.billingContract?.id && eventoId) {
tasks.push(
supabase
.from('agenda_eventos')
.update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() })
.eq('id', eventoId)
);
}
// 2) Aplicar multa (cria financial_record avulsa). Description leva
// data da sessão pra paciente identificar na fatura mesmo após cancel.
if (decision.applyFine && decision.fineAmount > 0) {
const dueIso = row.inicio_em ? new Date(row.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
const sessaoLabel = row.inicio_em ? new Date(row.inicio_em).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit' }) : '';
const fineDesc = novoStatus === 'faltou' ? `Multa por falta · sessão ${sessaoLabel}` : `Taxa de cancelamento tardio · sessão ${sessaoLabel}`;
const finePayload = {
owner_id: uid,
tenant_id: tenantId,
patient_id: patientId,
agenda_evento_id: eventoId,
amount: decision.fineAmount,
final_amount: decision.fineAmount,
description: fineDesc.trim(),
status: 'pending',
due_date: dueIso,
type: 'receita'
};
tasks.push(
supabase
.from('financial_records')
.insert(finePayload)
.then(({ error }) => {
if (error) {
console.warn('[Fase5] INSERT multa falhou:', error?.message, 'payload:', finePayload);
throw error;
}
})
);
}
// 2b) Cancelar cobrança original (faltou/cancelado + pendingRecord).
// A sessão não aconteceu/foi cancelada → original substituída pela
// multa (se aplicada) ou simplesmente cancelada. Sem isso cobrava
// dobrado: original R$200 pending + multa R$30 = R$230. Audit trail
// preserva original em notes.
const isFaltouOuCancelado = novoStatus === 'faltou' || novoStatus === 'cancelado';
if (isFaltouOuCancelado && ctx.pendingRecord?.id) {
const reasonText = decision.applyFine
? novoStatus === 'faltou'
? 'Cancelada — substituída por multa de no-show'
: 'Cancelada — substituída por taxa de cancelamento tardio'
: novoStatus === 'faltou'
? 'Cancelada — sessão não realizada (paciente faltou)'
: 'Cancelada — sessão cancelada';
const today = new Date().toISOString().slice(0, 10);
const noteEntry = `[${today}] ${reasonText}`;
const noteText = ctx.pendingRecord.notes ? `${ctx.pendingRecord.notes}\n${noteEntry}` : noteEntry;
tasks.push(
supabase
.from('financial_records')
.update({
status: 'cancelled',
notes: noteText,
updated_at: new Date().toISOString()
})
.eq('id', ctx.pendingRecord.id)
);
}
// 3) Realizado avulsa pendente: marcar pendingRecord como pago (ou só status)
if (decision.markPaid && ctx.pendingRecord?.id) {
tasks.push(
supabase
.from('financial_records')
.update({
status: 'paid',
paid_at: new Date().toISOString(),
payment_method: decision.paymentMethod || 'pix',
updated_at: new Date().toISOString()
})
.eq('id', ctx.pendingRecord.id)
);
}
// 4-pre) Realizado em pacote saldo + paid pré-existente (C12: antecipou)
// Sessão já paga via "Antecipar pagamento" anteriormente. Realizada
// agora não deve gerar record novo (duplicaria cobrança) — só
// amarrar contrato (via tasks 1b) + incrementar saldo. Rodamos os
// tasks pendentes antes do incremento pra não perder o link.
const hasAnticipatedPayment = novoStatus === 'realizado' && ctx.billingContract?.charging_style === 'saldo' && ctx.existingPaidRecord?.id;
if (hasAnticipatedPayment) {
// Roda tasks acumulados (basicamente 1b amarra) antes do incremento
if (tasks.length > 0) {
const results = await Promise.allSettled(tasks);
const failed = results.filter((r) => r.status === 'rejected');
if (failed.length > 0) {
console.warn('[Fase5/realizada-paid] tasks com falha:', failed.map((f) => f.reason?.message));
}
}
try {
const { data: freshContract, error: fetchErr } = await supabase
.from('billing_contracts')
.select('sessions_used, total_sessions, status')
.eq('id', ctx.billingContract.id)
.maybeSingle();
if (fetchErr) throw fetchErr;
const currentUsed = freshContract?.sessions_used ?? 0;
const newUsed = currentUsed + 1;
const patch = { sessions_used: newUsed };
if (newUsed >= (freshContract?.total_sessions ?? 0)) {
patch.status = 'completed';
}
const { error: incErr } = await supabase.from('billing_contracts').update(patch).eq('id', ctx.billingContract.id);
if (incErr) throw incErr;
toast.add({ severity: 'success', summary: 'Sessão consumida', detail: `Saldo: ${newUsed}/${freshContract?.total_sessions ?? '?'}. Pagamento já estava registrado.`, life: 4000 });
} catch (e) {
console.error('[Fase5/realizada-paid] erro consumindo saldo:', e?.message);
toast.add({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao consumir saldo', life: 5000 });
}
return;
}
// 4) Realizado em pacote saldo: amarra contract + cria cobrança + incrementa saldo
// Refatorado pra usar AWAITS SEQUENCIAIS (igual onUsarSessao do MelissaLayout).
// Antes era Promise.allSettled paralelo que escondia falhas silenciosas
// — durante teste C11/A o sessions_used não incrementava + agenda_evento
// ficava sem billing_contract_id. Ambos os updates não rodavam mas o
// toast warn não aparecia. Agora cada step tem error explícito.
if (decision.generatePackageCharge && ctx.billingContract?.id) {
const amount = Number(row.price ?? (ctx.billingContract.total_sessions > 0 ? (Number(ctx.billingContract.package_price) || 0) / ctx.billingContract.total_sessions : 0));
const dueIso = row.inicio_em ? new Date(row.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
// 4a) Amarra agenda_evento ao contrato. Pra virtual recém-materializada,
// _applyStatusUpdateOnly criou o evento SEM billing_contract_id —
// precisa update separado aqui.
try {
const { error: linkErr } = await supabase.from('agenda_eventos').update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() }).eq('id', eventoId);
if (linkErr) throw linkErr;
} catch (e) {
console.error('[Fase5] erro amarrando billing_contract_id:', e?.message);
toast.add({ severity: 'warn', summary: 'Pacote — amarração', detail: e?.message || 'Falha ao amarrar sessão ao pacote', life: 5000 });
}
// 4b) Cria financial_record (RPC tolera idempotência)
try {
const { error: rpcErr } = await supabase.rpc('create_financial_record_for_session', {
p_tenant_id: tenantId,
p_owner_id: uid,
p_patient_id: patientId,
p_agenda_evento_id: eventoId,
p_amount: amount,
p_due_date: dueIso
});
if (rpcErr) throw rpcErr;
} catch (e) {
console.error('[Fase5] erro RPC create_financial_record_for_session:', e?.message);
toast.add({ severity: 'error', summary: 'Cobrança', detail: e?.message || 'Falha ao gerar cobrança do pacote', life: 7000 });
}
// 4c) Incrementa sessions_used + completa contract se atingir total
// ⚠ billing_contracts NÃO tem coluna updated_at — passar esse
// campo causa "column does not exist" silenciosamente em
// Promise.allSettled (era o root cause do saldo não incrementar).
try {
const newUsed = (ctx.billingContract.sessions_used ?? 0) + 1;
const patchContract = { sessions_used: newUsed };
if (newUsed >= (ctx.billingContract.total_sessions ?? 0)) {
patchContract.status = 'completed';
}
const { error: incErr } = await supabase.from('billing_contracts').update(patchContract).eq('id', ctx.billingContract.id);
if (incErr) throw incErr;
} catch (e) {
console.error('[Fase5] erro incrementando sessions_used:', e?.message);
toast.add({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao atualizar saldo do pacote', life: 6000 });
}
}
// Roda tudo em paralelo (falha parcial é tolerável — toast warn)
const results = await Promise.allSettled(tasks);
const failed = results.filter((r) => r.status === 'rejected');
if (failed.length > 0) {
const firstErr = failed[0].reason?.message || 'sem detalhe';
toast.add({ severity: 'error', summary: 'Erro ao aplicar decisões', detail: `${failed.length} ação(ões) falharam: ${firstErr}`, life: 7000 });
console.error('[Fase5] falhas em _applyStatusDecisions:', failed.map((f) => f.reason));
} else if (tasks.length > 0) {
toast.add({ severity: 'success', summary: 'Status atualizado', detail: 'Decisões aplicadas.', life: 2500 });
}
// Pós-processamento do record gerado pelo pacote saldo. Agora o
// decision tem markPaid explícito:
// - markPaid=true → vira paid + payment_method=PIX/dinheiro/etc
// - markPaid=false + paymentMethod='link' → pending + payment_method='asaas'
// - markPaid=false + paymentMethod='pending' → pending sem método (default)
if (decision.generatePackageCharge && eventoId) {
try {
const { data: newRec } = await supabase
.from('financial_records')
.select('id')
.eq('agenda_evento_id', eventoId)
.order('created_at', { ascending: false })
.limit(1)
.single();
if (newRec?.id) {
if (decision.markPaid) {
await supabase
.from('financial_records')
.update({
status: 'paid',
paid_at: new Date().toISOString(),
payment_method: decision.paymentMethod,
updated_at: new Date().toISOString()
})
.eq('id', newRec.id);
} else if (decision.paymentMethod === 'link') {
await supabase
.from('financial_records')
.update({ payment_method: 'asaas', updated_at: new Date().toISOString() })
.eq('id', newRec.id);
}
// markPaid=false + paymentMethod='pending' → não faz nada
// (record já criado como pending pelo RPC, sem payment_method)
}
} catch { /* silencioso */ }
}
return applyStatusDecisions({
supabase,
toast,
eventoId,
row,
novoStatus,
ctx,
decision,
ownerId: ownerId.value,
tenantId: clinicTenantId.value
});
}
// ── onDialogSave / onDialogDelete ── populados na Stage 2 ──
const onDialogSave = _buildOnDialogSave(deps);
const onDialogDelete = _buildOnDialogDelete(deps);
@@ -2018,7 +1485,8 @@ function _buildOnDialogSave(deps) {
let chargeInfo = null;
if (createdRule?.id && recorrencia.commitmentItems?.length && recorrencia.qtdSessoes && normalized.paciente_id) {
if (recChargeMode === 'package') {
chargeInfo = await _createPackageContract({
chargeInfo = await _createPackageContractService({
supabase,
rule: createdRule,
normalized,
recorrencia,
@@ -2028,7 +1496,7 @@ function _buildOnDialogSave(deps) {
markPaidNow: arg?.markPaidNow === true
});
} else if (recChargeMode === 'per_session') {
chargeInfo = await _materializeAndChargePerSession({ rule: createdRule, normalized, recorrencia, tenantId: clinicId });
chargeInfo = await _materializeAndChargePerSessionService({ supabase, rule: createdRule, normalized, recorrencia, tenantId: clinicId });
}
}
@@ -2570,294 +2038,6 @@ function _buildOnDialogDelete(deps) {
};
}
// ───────────────────────────────────────────────────────────────────────────
// Helpers de cobrança em série (Opção C1, 2026-05-13)
// ───────────────────────────────────────────────────────────────────────────
const _BRL = (v) => Number(v ?? 0).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
// Calcula valor total da série a partir dos commitmentItems.
function _computeSeriePrice(recorrencia) {
const items = recorrencia.commitmentItems || [];
const n = recorrencia.qtdSessoes;
const totalPorSessao = items.reduce((s, i) => s + (i.final_price ?? 0), 0);
const pacoteFechado = recorrencia.serieValorMode === 'dividir';
return {
n,
perSessao: pacoteFechado ? totalPorSessao / n : totalPorSessao,
packagePrice: pacoteFechado ? totalPorSessao : totalPorSessao * n
};
}
// chargeMode='package' — 2 estilos (2026-05-14):
// - 'upfront' (default): cria billing_contract + materializa 1ª ocorrência
// em agenda_eventos + cria 1 financial_record com valor TOTAL do pacote
// (vencimento na data da 1ª sessão). Demais ocorrências continuam virtuais.
// Suporta paymentMethod + markPaidNow — marca record como pago quando true.
// - 'saldo': só cria billing_contract (Cliniko style). Sem financial_record
// imediato — cobranças individuais nascem conforme sessões.
async function _createPackageContract({ rule, normalized, recorrencia, tenantId, packageStyle = 'upfront', paymentMethod = 'link', markPaidNow = false }) {
const { n, packagePrice } = _computeSeriePrice(recorrencia);
try {
// 1) billing_contract — referência do pacote em ambos os estilos.
// charging_style: identifica como o pacote foi cobrado na criação;
// handler de status change usa pra decidir entre "só status" (upfront)
// ou "criar cobrança + consumir saldo" (saldo).
const { data: createdContract, error: contractErr } = await supabase
.from('billing_contracts')
.insert({
owner_id: normalized.owner_id,
tenant_id: tenantId,
patient_id: normalized.paciente_id,
type: 'package',
total_sessions: n,
sessions_used: 0,
package_price: packagePrice,
status: 'active',
charging_style: packageStyle === 'saldo' ? 'saldo' : 'upfront'
})
.select('id')
.single();
if (contractErr) throw contractErr;
const contractId = createdContract?.id ?? null;
// Estilo 'saldo': para aqui — sem cobrança imediata.
if (packageStyle === 'saldo') {
return {
toast: {
severity: 'success',
summary: 'Pacote criado (saldo)',
detail: `${n} sessões — total ${_BRL(packagePrice)}. Cobranças individuais conforme sessões.`,
life: 3500
}
};
}
// Estilo 'upfront': materializa 1ª ocorrência + 1 financial_record total.
const durMin = rule.duration_min || 50;
const [hh, mm] = String(rule.start_time || '00:00:00').slice(0, 5).split(':').map(Number);
const firstISO = rule.start_date;
const startDt = new Date(`${firstISO}T00:00:00`);
startDt.setHours(hh, mm, 0, 0);
const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
const { data: createdEvent, error: evErr } = await supabase
.from('agenda_eventos')
.insert({
owner_id: rule.owner_id,
tenant_id: tenantId,
terapeuta_id: rule.therapist_id ?? null,
recurrence_id: rule.id,
recurrence_date: firstISO,
tipo: 'sessao',
status: 'agendado',
titulo: normalized.titulo || 'Sessão',
inicio_em: startDt.toISOString(),
fim_em: endDt.toISOString(),
patient_id: normalized.paciente_id,
determined_commitment_id: normalized.determined_commitment_id ?? null,
modalidade: rule.modalidade || normalized.modalidade || 'presencial',
price: packagePrice,
billing_contract_id: contractId,
visibility_scope: normalized.visibility_scope || 'public'
})
.select('id')
.single();
if (evErr) throw evErr;
// 2) financial_record do pacote total via RPC.
const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', {
p_tenant_id: tenantId,
p_owner_id: rule.owner_id,
p_patient_id: normalized.paciente_id ?? null,
p_agenda_evento_id: createdEvent.id,
p_amount: packagePrice,
p_due_date: firstISO
});
if (cobErr) throw cobErr;
// 3) Pós-RPC: ajusta payment_method (sempre) e status (só se markPaidNow=true).
// method='link' → payment_method='asaas', status pending
// method=pix/etc + markPaidNow=false → payment_method=<>, status pending
// method=pix/etc + markPaidNow=true → payment_method=<>, status='paid', paid_at=now()
const paidNow = markPaidNow === true && paymentMethod !== 'link';
const { data: recRow } = await supabase
.from('financial_records')
.select('id')
.eq('agenda_evento_id', createdEvent.id)
.order('created_at', { ascending: false })
.limit(1)
.single();
if (recRow?.id) {
const patch = {
updated_at: new Date().toISOString(),
payment_method: paymentMethod === 'link' ? 'asaas' : paymentMethod
};
if (paidNow) {
patch.status = 'paid';
patch.paid_at = new Date().toISOString();
}
await supabase.from('financial_records').update(patch).eq('id', recRow.id);
}
const methodLabel = {
pix: 'PIX',
dinheiro: 'dinheiro',
deposito: 'depósito',
cartao_maquininha: 'cartão (maquininha)'
}[paymentMethod] || null;
return {
toast: {
severity: 'success',
summary: paidNow ? 'Pacote pago' : 'Pacote criado',
detail: paidNow
? `${n} sessões — ${_BRL(packagePrice)} recebido via ${methodLabel}.`
: `${n} sessões — total ${_BRL(packagePrice)} com vencimento em ${firstISO.split('-').reverse().join('/')}.`,
life: 4000
}
};
} catch (e) {
return {
toast: {
severity: 'warn',
summary: 'Pacote não gerado',
detail: e?.message || 'Falha ao criar contrato. Você pode gerar manualmente pelo Financeiro.',
life: 5000
}
};
}
}
// chargeMode='per_session': materializa todas as N ocorrências como
// agenda_eventos reais + cria 1 financial_record por ocorrência.
// Respeita recurrence_exceptions (feriado_block / cancel_session) — não
// materializa nessas datas. Falha parcial é tolerada (toast warn).
async function _materializeAndChargePerSession({ rule, normalized, recorrencia, tenantId }) {
const { n, perSessao } = _computeSeriePrice(recorrencia);
try {
// 1) Gerar a lista de datas das N ocorrências respeitando exceptions.
const exceptionDates = new Set((recorrencia.conflitos || []).map((c) => c.date));
const dates = _generateOccurrenceDates(rule, n + exceptionDates.size, exceptionDates).slice(0, n);
// 2) Montar rows pra inserção em agenda_eventos.
const durMin = rule.duration_min || 50;
const [hh, mm] = String(rule.start_time || '00:00:00').slice(0, 5).split(':').map(Number);
const rows = dates.map((iso) => {
const startDt = new Date(`${iso}T00:00:00`);
startDt.setHours(hh, mm, 0, 0);
const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
return {
owner_id: rule.owner_id,
tenant_id: tenantId,
terapeuta_id: rule.therapist_id ?? null,
recurrence_id: rule.id,
recurrence_date: iso,
tipo: 'sessao',
status: 'agendado',
titulo: normalized.titulo || 'Sessão',
inicio_em: startDt.toISOString(),
fim_em: endDt.toISOString(),
patient_id: normalized.paciente_id,
determined_commitment_id: normalized.determined_commitment_id ?? null,
modalidade: rule.modalidade || normalized.modalidade || 'presencial',
price: perSessao,
visibility_scope: normalized.visibility_scope || 'public'
};
});
// 3) Insert batch dos eventos.
const { data: createdEvents, error: evErr } = await supabase.from('agenda_eventos').insert(rows).select('id, inicio_em');
if (evErr) throw evErr;
// 4) Pra cada evento criado, criar financial_record via RPC. Loop
// sequencial pra simplificar — N=4 é pouco; se virar gargalo, batchify.
let okCount = 0;
let failCount = 0;
for (const ev of createdEvents || []) {
try {
const dueDate = ev.inicio_em ? new Date(ev.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', {
p_tenant_id: tenantId,
p_owner_id: rule.owner_id,
p_patient_id: normalized.paciente_id ?? null,
p_agenda_evento_id: ev.id,
p_amount: perSessao,
p_due_date: dueDate
});
if (cobErr) throw cobErr;
okCount++;
} catch {
failCount++;
}
}
if (failCount === 0) {
return {
toast: {
severity: 'success',
summary: `${okCount} cobranças geradas`,
detail: `${_BRL(perSessao)} por sessão. Total: ${_BRL(perSessao * okCount)}.`,
life: 4000
}
};
}
return {
toast: {
severity: 'warn',
summary: 'Cobranças parcialmente geradas',
detail: `${okCount} ok, ${failCount} falharam. Gere as faltantes manualmente pelo Financeiro.`,
life: 6000
}
};
} catch (e) {
return {
toast: {
severity: 'warn',
summary: 'Falha ao materializar série',
detail: e?.message || 'Sessões podem ter sido criadas em parte. Confira no Financeiro.',
life: 6000
}
};
}
}
// Gera lista de datas ISO ('YYYY-MM-DD') a partir da regra. Pula datas
// em exceptionDates (Set). Para até `max` datas. Suporta weekly (interval=1
// ou 2 pra quinzenal) e custom_weekdays.
function _generateOccurrenceDates(rule, max, exceptionDates) {
const dates = [];
const start = new Date(`${rule.start_date}T00:00:00`);
const interval = Math.max(1, rule.interval || 1);
const weekdays = Array.isArray(rule.weekdays) && rule.weekdays.length ? rule.weekdays.map(Number) : [start.getDay()];
const isCustom = rule.type === 'custom_weekdays';
const cursor = new Date(start);
let safety = 0;
// Para weekly (interval=1), avança 7 dias por iteração. Quinzenal: 14.
// Para custom_weekdays, avança 1 dia e filtra weekdays.includes.
while (dates.length < max && safety < 365 * 3) {
const iso = _dateToISO(cursor);
const dow = cursor.getDay();
const inWeekdays = weekdays.includes(dow);
if (inWeekdays && !exceptionDates.has(iso)) {
dates.push(iso);
}
if (isCustom) {
cursor.setDate(cursor.getDate() + 1);
} else if (inWeekdays) {
// weekly/quinzenal: ao bater o dow, pula interval semanas
cursor.setDate(cursor.getDate() + 7 * interval);
} else {
cursor.setDate(cursor.getDate() + 1);
}
safety++;
}
return dates;
}
function _dateToISO(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
// _generateOccurrenceDates extraído pra agendaBilling.service (Fase B1) — import no topo.
// _dateToISO foi extraído pra @/features/agenda/utils/timeHelpers — import no topo.
+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
]
},
+5
View File
@@ -26,6 +26,11 @@ export default [
items: [{ label: 'Sessões', icon: 'pi pi-fw pi-calendar', to: '/portal/sessoes' }]
},
{
label: 'Documentos',
items: [{ label: 'Para assinar', icon: 'pi pi-fw pi-file-edit', to: '/portal/documentos' }]
},
{
label: 'Conta',
items: [
+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' }
}
]
};
+6
View File
@@ -27,6 +27,12 @@ export default {
name: 'portal-sessoes',
component: () => import('@/views/pages/portal/MinhasSessoes.vue')
},
{
path: 'documentos',
name: 'portal-documentos',
component: () => import('@/views/pages/portal/PortalDocumentos.vue'),
meta: { area: 'portal', requiresAuth: true }
},
// ======================================================
// 💳 MEU PLANO (assinatura pessoal do paciente)
+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',
+318 -32
View File
@@ -15,9 +15,39 @@
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
import { valorExtenso } from '@/utils/valorExtenso';
const BUCKET = 'generated-docs';
// Mapa tipo do template → tipo_documento (CHECK constraint de documents).
// Recibos vão pra 'recibo'; demais ficam em 'outro' (CHECK não tem entradas
// específicas pra cada consent form, mas 'outro' é válido).
const TEMPLATE_TYPE_TO_DOC_TYPE = {
recibo_pagamento: 'recibo',
declaracao_comparecimento: 'declaracao',
declaracao_inicio_tratamento: 'declaracao',
atestado_psicologico: 'atestado',
relatorio_acompanhamento: 'relatorio_externo',
laudo_psicologico: 'laudo',
parecer_psicologico: 'laudo',
encaminhamento: 'declaracao'
};
function mapTipoDocumento(templateTipo) {
return TEMPLATE_TYPE_TO_DOC_TYPE[templateTipo] || 'outro';
}
/**
* Formata o registro profissional: "CRP 12345/SP".
* Funciona com qualquer conselho (CRP, CRM, CRFa, CREFITO etc).
*/
function formatRegistroProfissional({ tipo, numero, uf }) {
if (!tipo || !numero) return '';
const parts = [tipo, numero];
if (uf) parts[1] = `${numero}/${uf}`;
return parts.join(' ');
}
// ── Helpers ──────────────────────────────────────────────────
async function getOwnerId() {
@@ -106,13 +136,19 @@ export async function loadSessionData(agendaEventoId) {
/**
* Busca dados do terapeuta (profile + tenant_member).
*
* Inclui registro profissional (CRP/CRM/CRFa etc) adicionado pela migration
* 20260521000003. Retorna:
* - terapeuta_registro: "CRP 12345/SP" auto-formatado (genérico p/ qualquer conselho)
* - terapeuta_crp: compat preenchido se o tipo for CRP (templates legacy)
* - terapeuta_registro_tipo / _numero / _uf: campos individuais p/ uso fino
*/
export async function loadTherapistData() {
const ownerId = await getOwnerId();
const { data: profile } = await supabase
.from('profiles')
.select('full_name, phone')
.select('full_name, phone, professional_registration_type, professional_registration_type_other, professional_registration_number, professional_registration_uf')
.eq('id', ownerId)
.single();
@@ -120,20 +156,37 @@ export async function loadTherapistData() {
const { data: userData } = await supabase.auth.getUser();
const email = userData?.user?.email || '';
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 });
return {
terapeuta_nome: profile?.full_name || '',
terapeuta_crp: '', // CRP ainda nao existe no banco — preencher manualmente
terapeuta_email: email,
terapeuta_telefone: profile?.phone || ''
terapeuta_telefone: profile?.phone || '',
terapeuta_registro: registro,
terapeuta_registro_tipo: tipo,
terapeuta_registro_numero: numero,
terapeuta_registro_uf: uf,
// Compat: templates antigos referenciam {{terapeuta_crp}} — preenche só
// 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: tipoRaw === 'CRP' ? (uf ? `${numero}/${uf}` : numero) : ''
};
}
/**
* Busca dados da clinica (tenant).
*
* `tenants.cpf_cnpj` aceita 11 (CPF) ou 14 (CNPJ) dígitos. Pra `clinica_cnpj`
* preenche se tiver 14 dígitos (formatado XX.XXX.XXX/XXXX-XX).
*/
export async function loadClinicData(tenantId) {
// Usa select('*') pois campos de endereço (logradouro, numero, etc.)
// dependem da migration 003_tenants_address_fields ter sido aplicada
const { data: tenant } = await supabase
.from('tenants')
.select('*')
@@ -144,17 +197,24 @@ export async function loadClinicData(tenantId) {
return { clinica_nome: '', clinica_endereco: '', clinica_telefone: '', clinica_cnpj: '' };
}
// Usa campos estruturados se disponiveis, senao cai no address texto livre
const endereco = tenant.logradouro
? [tenant.logradouro, tenant.numero, tenant.bairro, tenant.cidade, tenant.estado]
.filter(Boolean).join(', ')
: tenant.address || '';
const digits = String(tenant.cpf_cnpj || '').replace(/\D/g, '');
let cnpj = '';
if (digits.length === 14) {
cnpj = digits.replace(/^(\d{2})(\d{3})(\d{3})(\d{4})(\d{2})$/, '$1.$2.$3/$4-$5');
} else if (digits.length === 11) {
cnpj = digits.replace(/^(\d{3})(\d{3})(\d{3})(\d{2})$/, '$1.$2.$3-$4');
}
return {
clinica_nome: tenant.name || '',
clinica_endereco: endereco,
clinica_telefone: tenant.phone || '',
clinica_cnpj: ''
clinica_cnpj: cnpj
};
}
@@ -174,28 +234,71 @@ function getDateVariables() {
/**
* Carrega todos os dados necessarios para preencher um template.
*
* @param {string} patientId
* @param {string|null} agendaEventoId - sessão p/ data_sessao + valor
* @param {object} extras - overrides ad-hoc (ex: { valor: 150, forma_pagamento: 'PIX' })
*/
export async function loadAllVariables(patientId, agendaEventoId = null) {
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
});
}
return {
// 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 = Number.isFinite(valorNum)
? `R$ ${valorNum.toFixed(2).replace('.', ',')}`
: (session.valor || '');
const valorExtensoStr = Number.isFinite(valorNum) ? valorExtenso(valorNum) : '';
const merged = {
...patient,
...session,
...therapist,
...clinic,
...getDateVariables(),
// Overrides explícitos (forma_pagamento, valor, qualquer outra chave)
...extras,
// Computados — sempre rebaixam extras nos campos que controlamos
valor: valorFormatted,
valor_extenso: valorExtensoStr,
forma_pagamento: extras.forma_pagamento || session.forma_pagamento || '',
cidade_estado: clinic.clinica_endereco
? `${clinic.clinica_endereco.split(', ').slice(-2).join('/')}`
: ''
};
return merged;
}
// ── Preencher template ──────────────────────────────────────
@@ -301,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 }) {
export async function saveGeneratedDocument({ templateId, patientId, dadosPreenchidos, pdfBlob, templateNome, templateTipo, editingDocId = null }) {
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
@@ -325,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,
@@ -356,18 +523,137 @@ export async function saveGeneratedDocument({ templateId, patientId, dadosPreenc
nome_original: filename.replace(/_/g, ' ').replace('.pdf', '') + '.pdf',
mime_type: 'application/pdf',
tamanho_bytes: pdfBlob?.size || null,
tipo_documento: 'laudo',
tipo_documento: mapTipoDocumento(templateTipo),
descricao: `Gerado a partir do template: ${templateNome || 'documento'}`,
tags: ['gerado'],
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;
}
// ── Quick path: emitir recibo de sessão ─────────────────────
//
// Wrapper de alto nível que tudo numa chamada só:
// 1. busca template global 'recibo_pagamento'
// 2. carrega variáveis (paciente + sessão + terapeuta + clínica + valor + extenso)
// 3. gera PDF
// 4. salva no Storage + tabelas document_generated + documents
// 5. dispara download
//
// Usado pelo popover da agenda e painel financeiro do paciente.
// Pra fluxo customizado (escolher template, editar variáveis, etc),
// continue usando DocumentGenerateDialog → useDocumentGenerate.
//
export async function emitirReciboParaSessao(agendaEventoId, { patientId, valor, formaPagamento } = {}) {
if (!agendaEventoId && !patientId) {
throw new Error('Informe agendaEventoId ou patientId.');
}
// Resolve patient_id pela sessão se não veio
let resolvedPatientId = patientId;
if (!resolvedPatientId && agendaEventoId) {
const { data, error } = await supabase
.from('agenda_eventos')
.select('paciente_id')
.eq('id', agendaEventoId)
.single();
if (error) throw error;
resolvedPatientId = data?.paciente_id;
if (!resolvedPatientId) throw new Error('Sessão sem paciente vinculado.');
}
// Busca template global recibo_pagamento
const { data: tpl, error: tplErr } = await supabase
.from('document_templates')
.select('*')
.eq('tipo', 'recibo_pagamento')
.eq('is_global', true)
.eq('ativo', true)
.limit(1)
.single();
if (tplErr) throw tplErr;
if (!tpl) throw new Error('Template de recibo não encontrado.');
// Variáveis (extras sobrescrevem session.valor / forma_pagamento)
const extras = {};
if (valor != null) extras.valor = valor;
if (formaPagamento) extras.forma_pagamento = formaPagamento;
const variables = await loadAllVariables(resolvedPatientId, agendaEventoId, extras);
// Gera PDF
const blob = await generatePdfBlob(tpl, variables);
// Salva
const saved = await saveGeneratedDocument({
templateId: tpl.id,
patientId: resolvedPatientId,
dadosPreenchidos: variables,
pdfBlob: blob,
templateNome: tpl.nome_template,
templateTipo: tpl.tipo
});
// Download client-side
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
const dataStr = (variables.data_sessao || variables.data_atual || '').replace(/\//g, '-');
a.download = `recibo_${(variables.paciente_nome || 'paciente').replace(/\s+/g, '_')}_${dataStr || 'sessao'}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
return { generated: saved, variables, blob };
}
/**
* Lista documentos gerados de um paciente.
*/
@@ -170,3 +170,71 @@ export async function refuseSignature(signatureId) {
if (error) throw error;
return data;
}
// ── Assinar via portal (paciente logado) ─────────────────────
//
// IP e user-agent são capturados SERVER-SIDE pela RPC via
// inet_client_addr() e current_setting('request.headers'). O cliente
// só passa o hash SHA-256 do PDF (gerado via hashDocument()) pra
// garantir integridade do documento no momento da assinatura.
//
export async function signByPortal(signatureId, hashDocumento = null) {
if (!signatureId) throw new Error('ID da assinatura inválido.');
const { data, error } = await supabase.rpc('sign_document_by_signature_id', {
p_signature_id: signatureId,
p_hash_documento: hashDocumento || null
});
if (error) throw error;
return data;
}
// ── Assinar via token público (share link) ──────────────────
//
// Para signatários não-logados acessando via /shared/document/:token.
// p_signature_id é opcional — quando o documento tem múltiplos
// signatários, identifica qual deles está assinando. Quando há apenas
// um pendente, deixa null e o backend resolve.
//
export async function signByToken(token, signatureId = null, hashDocumento = null) {
if (!token) throw new Error('Token inválido.');
const { data, error } = await supabase.rpc('sign_document_by_token', {
p_token: token,
p_signature_id: signatureId || null,
p_hash_documento: hashDocumento || null
});
if (error) throw error;
return data;
}
// ── Listar minhas assinaturas (portal do paciente) ──────────
//
// Wrapper sobre RPC list_my_signatures. Resolve auth.uid() server-side
// e cruza por signatario_id / email / patient.user_id pra encontrar
// todas as assinaturas que pertencem ao usuário logado. Inclui
// share_token p/ apontar direto pra /shared/document/:token.
//
export async function listMySignatures(statusFilter = null) {
const { data, error } = await supabase.rpc('list_my_signatures', {
p_status: Array.isArray(statusFilter) ? statusFilter : null
});
if (error) throw error;
return data || [];
}
// ── Pré-visualizar documento por token (sem assinar) ────────
//
// Usado pela página pública pra carregar info do documento +
// signatários pendentes ANTES do click em "Assinar". Retorna
// { valid, document, signatures, expira_em, usos_restantes }.
//
export async function getSignableDocumentByToken(token) {
if (!token) throw new Error('Token inválido.');
const { data, error } = await supabase.rpc('get_signable_document_by_token', {
p_token: token
});
if (error) throw error;
return data;
}
+43 -32
View File
@@ -44,45 +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_crp', label: 'CRP do terapeuta', 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 ────────────────────────────────────────────────────
+281
View File
@@ -0,0 +1,281 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/services/reportExport.service.js
|
| Export de relatórios em PDF (via pdf.service) e Excel (.xlsx via exceljs).
| Foco em sessões da agenda o relatório principal do MVP.
|
| Outros relatórios (financeiro, evolução por escala etc) podem reusar
| as mesmas helpers genéricas (buildSheetFromRows, buildPdfHtml).
|--------------------------------------------------------------------------
*/
import { htmlToPdfDownload } from '@/services/pdf.service';
const STATUS_LABEL = {
agendado: 'Agendado',
realizado: 'Realizado',
faltou: 'Faltou',
cancelado: 'Cancelado',
remarcado: 'Remarcado'
};
function fmtDateTime(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString('pt-BR') + ' ' + d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
function fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString('pt-BR');
}
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[c]));
}
function safeFilename(base, ext) {
const slug = String(base || 'relatorio')
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-zA-Z0-9_-]/g, '_').replace(/_+/g, '_');
const ts = new Date().toISOString().slice(0, 10).replace(/-/g, '');
return `${slug}_${ts}.${ext}`;
}
// ── PDF ───────────────────────────────────────────────────────────────────
/**
* Gera PDF do relatório de sessões. Layout simples: header + KPIs + tabela.
*
* @param {object} params
* @param {string} params.title Ex: "Relatório de Sessões"
* @param {string} params.subtitle Ex: "Outubro/2026" qualquer texto
* @param {Array} params.sessions Lista normalizada (paciente_nome, inicio_em, status, modalidade, tipo)
* @param {Array} params.kpis [{ label, value }] opcional
* @param {string} params.tenantName Nome da clínica (cabeçalho)
* @param {string} params.terapeutaNome Nome do terapeuta (subtitle)
*/
export async function exportSessionsToPDF({ title = 'Relatório de Sessões', subtitle = '', sessions = [], kpis = [], tenantName = '', terapeutaNome = '' } = {}) {
const rows = sessions.map(s => `
<tr>
<td>${fmtDateTime(s.inicio_em)}</td>
<td>${escapeHtml(s.paciente_nome || s.patients?.nome_completo || '—')}</td>
<td>${escapeHtml(STATUS_LABEL[s.status] || s.status || 'Agendado')}</td>
<td>${escapeHtml(s.modalidade || '—')}</td>
<td>${escapeHtml(s.tipo || s.titulo || '—')}</td>
</tr>
`).join('');
const kpiHtml = kpis.length ? `
<table class="kpi-grid">
<tr>
${kpis.map(k => `
<td>
<div class="kpi-label">${escapeHtml(k.label)}</div>
<div class="kpi-value">${escapeHtml(String(k.value ?? '—'))}</div>
</td>
`).join('')}
</tr>
</table>
` : '';
const html = `
<!DOCTYPE html>
<html lang="pt-BR" style="color-scheme:light;">
<head>
<meta charset="UTF-8">
<style>
@page { size: A4; margin: 18mm 12mm; }
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 11pt; color: #1a1a1a; }
h1 { font-size: 18pt; margin: 0 0 4px; }
h2 { font-size: 12pt; margin: 0 0 16px; color: #4b5563; font-weight: 500; }
.header { border-bottom: 2px solid #1d4ed8; padding-bottom: 10px; margin-bottom: 20px; }
.header__brand { font-size: 10pt; color: #6b7280; margin-bottom: 4px; }
.kpi-grid { width: 100%; border-collapse: collapse; margin-bottom: 22px; }
.kpi-grid td { background: #f3f4f6; border: 1px solid #e5e7eb; padding: 10px 12px; text-align: center; width: ${Math.floor(100 / Math.max(kpis.length, 1))}%; }
.kpi-label { font-size: 9pt; color: #6b7280; text-transform: uppercase; letter-spacing: 0.04em; }
.kpi-value { font-size: 16pt; font-weight: 700; color: #1d4ed8; margin-top: 4px; }
table.data { width: 100%; border-collapse: collapse; font-size: 10pt; }
table.data th { background: #1e293b; color: white; padding: 8px 10px; text-align: left; font-weight: 600; font-size: 9pt; text-transform: uppercase; letter-spacing: 0.02em; }
table.data td { border-bottom: 1px solid #e5e7eb; padding: 7px 10px; vertical-align: top; }
table.data tr:nth-child(even) td { background: #f9fafb; }
.footer { margin-top: 24px; font-size: 9pt; color: #9ca3af; text-align: center; border-top: 1px solid #e5e7eb; padding-top: 8px; }
.empty { text-align: center; padding: 40px 0; color: #9ca3af; font-style: italic; }
</style>
</head>
<body>
<div class="header">
<div class="header__brand">${escapeHtml(tenantName)}${terapeutaNome ? ' · ' + escapeHtml(terapeutaNome) : ''}</div>
<h1>${escapeHtml(title)}</h1>
${subtitle ? `<h2>${escapeHtml(subtitle)}</h2>` : ''}
</div>
${kpiHtml}
${sessions.length === 0
? '<div class="empty">Nenhuma sessão encontrada no período selecionado.</div>'
: `<table class="data">
<thead>
<tr>
<th style="width: 140px;">Data/hora</th>
<th>Paciente</th>
<th style="width: 100px;">Status</th>
<th style="width: 90px;">Modalidade</th>
<th style="width: 100px;">Tipo</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>`}
<div class="footer">
Gerado por AgênciaPSI em ${fmtDate(new Date().toISOString())} · ${sessions.length} sessão(ões)
</div>
</body>
</html>`.trim();
const filename = safeFilename(title, 'pdf');
await htmlToPdfDownload(html, filename);
return filename;
}
// ── Excel (.xlsx via exceljs) ─────────────────────────────────────────────
/**
* Gera planilha .xlsx do relatório de sessões.
* Cabeçalhos formatados, larguras de coluna razoáveis, 1 worksheet "Sessões".
*/
export async function exportSessionsToXLSX({ title = 'Relatorio_Sessoes', subtitle = '', sessions = [], kpis = [], tenantName = '' } = {}) {
// Import dinâmico — exceljs é pesado, só carrega quando user clicar export
const ExcelJSModule = await import('exceljs');
const ExcelJS = ExcelJSModule.default || ExcelJSModule;
const wb = new ExcelJS.Workbook();
wb.creator = tenantName || 'AgênciaPSI';
wb.created = new Date();
const ws = wb.addWorksheet('Sessões', {
properties: { tabColor: { argb: 'FF1D4ED8' } },
views: [{ state: 'frozen', ySplit: 4 }]
});
// Header da planilha (linhas 1-3): título + subtitle + KPIs
ws.mergeCells('A1:E1');
const titleCell = ws.getCell('A1');
titleCell.value = title;
titleCell.font = { size: 16, bold: true, color: { argb: 'FF1D4ED8' } };
titleCell.alignment = { vertical: 'middle', horizontal: 'left' };
ws.getRow(1).height = 24;
if (subtitle) {
ws.mergeCells('A2:E2');
const subCell = ws.getCell('A2');
subCell.value = subtitle;
subCell.font = { size: 11, color: { argb: 'FF6B7280' } };
ws.getRow(2).height = 18;
}
if (kpis.length) {
ws.mergeCells('A3:E3');
const kpiCell = ws.getCell('A3');
kpiCell.value = kpis.map(k => `${k.label}: ${k.value}`).join(' · ');
kpiCell.font = { size: 10, italic: true, color: { argb: 'FF374151' } };
ws.getRow(3).height = 18;
}
// Header da tabela (linha 4)
const headerRow = ws.getRow(4);
headerRow.values = ['Data/hora', 'Paciente', 'Status', 'Modalidade', 'Tipo'];
headerRow.eachCell((cell) => {
cell.font = { bold: true, color: { argb: 'FFFFFFFF' }, size: 10 };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1E293B' } };
cell.alignment = { vertical: 'middle', horizontal: 'left' };
cell.border = { top: { style: 'thin' }, bottom: { style: 'thin' } };
});
headerRow.height = 20;
// Larguras (em "characters" do Excel)
ws.columns = [
{ width: 20 },
{ width: 32 },
{ width: 14 },
{ width: 14 },
{ width: 16 }
];
// Linhas de dados
sessions.forEach((s, idx) => {
const r = ws.addRow([
fmtDateTime(s.inicio_em),
s.paciente_nome || s.patients?.nome_completo || '—',
STATUS_LABEL[s.status] || s.status || 'Agendado',
s.modalidade || '—',
s.tipo || s.titulo || '—'
]);
if (idx % 2 === 1) {
r.eachCell((cell) => {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF9FAFB' } };
});
}
});
// Footer numa célula isolada
const lastRow = ws.lastRow.number + 2;
ws.mergeCells(`A${lastRow}:E${lastRow}`);
const footerCell = ws.getCell(`A${lastRow}`);
footerCell.value = `Gerado em ${fmtDate(new Date().toISOString())} · ${sessions.length} registro(s)`;
footerCell.font = { size: 9, italic: true, color: { argb: 'FF9CA3AF' } };
// Download via blob
const buf = await wb.xlsx.writeBuffer();
const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const filename = safeFilename(title, 'xlsx');
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
return filename;
}
// ── CSV fallback (vanilla, sem deps) ──────────────────────────────────────
export function exportSessionsToCSV({ title = 'Relatorio_Sessoes', sessions = [] } = {}) {
const csvEscape = (v) => {
const s = String(v ?? '');
if (/[",\n;]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
};
const headers = ['Data/hora', 'Paciente', 'Status', 'Modalidade', 'Tipo'];
const rows = sessions.map(s => [
fmtDateTime(s.inicio_em),
s.paciente_nome || s.patients?.nome_completo || '',
STATUS_LABEL[s.status] || s.status || 'Agendado',
s.modalidade || '',
s.tipo || s.titulo || ''
]);
const csv = [headers, ...rows].map(r => r.map(csvEscape).join(';')).join('\r\n');
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8' });
const filename = safeFilename(title, 'csv');
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
return filename;
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Converte um valor monetário em reais (pt-BR) para extenso.
*
* Ex: 1234.56 "mil duzentos e trinta e quatro reais e cinquenta e seis centavos"
* 150 "cento e cinquenta reais"
* 0.75 "setenta e cinco centavos"
*
* Usado em recibos profissionais e documentos formais (CFP exige valor por
* extenso em alguns modelos). Não-localizado a R$ o número por extenso
* (o template adiciona "R$" no prefixo se quiser).
*/
const UNIDADES = ['', 'um', 'dois', 'três', 'quatro', 'cinco', 'seis', 'sete', 'oito', 'nove'];
const DEZ_A_DEZENOVE = ['dez', 'onze', 'doze', 'treze', 'quatorze', 'quinze', 'dezesseis', 'dezessete', 'dezoito', 'dezenove'];
const DEZENAS = ['', '', 'vinte', 'trinta', 'quarenta', 'cinquenta', 'sessenta', 'setenta', 'oitenta', 'noventa'];
const CENTENAS = ['', 'cento', 'duzentos', 'trezentos', 'quatrocentos', 'quinhentos', 'seiscentos', 'setecentos', 'oitocentos', 'novecentos'];
function centenaPorExtenso(n) {
if (n === 0) return '';
if (n === 100) return 'cem';
const c = Math.floor(n / 100);
const dezAUm = n % 100;
const d = Math.floor(dezAUm / 10);
const u = dezAUm % 10;
const parts = [];
if (c > 0) parts.push(CENTENAS[c]);
if (d === 1) {
parts.push(DEZ_A_DEZENOVE[u]);
} else {
if (d > 1) parts.push(DEZENAS[d]);
if (u > 0) {
if (d > 1) parts.push('e ' + UNIDADES[u]);
else parts.push(UNIDADES[u]);
}
}
return parts.join(' e ').replace(/ e $/, '');
}
function grupoPorExtenso(n) {
if (n === 0) return '';
return centenaPorExtenso(n);
}
/**
* Converte um número inteiro até 999.999.999 para extenso em português.
*/
function inteiroPorExtenso(n) {
if (n === 0) return 'zero';
const milhao = Math.floor(n / 1000000);
const milhar = Math.floor((n % 1000000) / 1000);
const centena = n % 1000;
const parts = [];
if (milhao > 0) {
parts.push(milhao === 1 ? 'um milhão' : grupoPorExtenso(milhao) + ' milhões');
}
if (milhar > 0) {
parts.push(milhar === 1 ? 'mil' : grupoPorExtenso(milhar) + ' mil');
}
if (centena > 0) {
parts.push(grupoPorExtenso(centena));
}
return parts.join(' e ').trim();
}
/**
* Valor monetário (Number) "X reais e Y centavos".
*
* @param {number|string} valor
* @returns {string} vazio se valor inválido
*/
export function valorExtenso(valor) {
const num = Number(valor);
if (!isFinite(num) || num < 0) return '';
const reais = Math.floor(num);
const centavos = Math.round((num - reais) * 100);
const partes = [];
if (reais > 0) {
partes.push(inteiroPorExtenso(reais) + (reais === 1 ? ' real' : ' reais'));
}
if (centavos > 0) {
partes.push(inteiroPorExtenso(centavos) + (centavos === 1 ? ' centavo' : ' centavos'));
}
if (partes.length === 0) return 'zero real';
return partes.join(' e ');
}
export default valorExtenso;
+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"
+197
View File
@@ -0,0 +1,197 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI Portal do Paciente · Documentos para assinatura
|--------------------------------------------------------------------------
| Lista as solicitações de assinatura do paciente logado:
| - pendente/enviado botão "Assinar agora" /shared/document/:token
| - assinado mostra data + hash + IP (audit)
| - recusado mostra data + motivo (se houver)
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useDocumentSignatures } from '@/features/documents/composables/useDocumentSignatures';
const router = useRouter();
const { signatures, loading, error, loadMine } = useDocumentSignatures();
const statusFilter = ref('todos'); // todos | pendentes | assinados
const filtered = computed(() => {
const all = signatures.value || [];
if (statusFilter.value === 'pendentes') {
return all.filter(s => s.status === 'pendente' || s.status === 'enviado');
}
if (statusFilter.value === 'assinados') {
return all.filter(s => s.status === 'assinado');
}
return all;
});
const counts = computed(() => {
const all = signatures.value || [];
return {
total: all.length,
pendentes: all.filter(s => s.status === 'pendente' || s.status === 'enviado').length,
assinados: all.filter(s => s.status === 'assinado').length,
recusados: all.filter(s => s.status === 'recusado').length
};
});
function fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleDateString('pt-BR') + ' ' + d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
function statusBadge(s) {
const map = {
pendente: { label: 'Pendente', class: 'bg-amber-100 text-amber-700 border-amber-200' },
enviado: { label: 'Enviado', class: 'bg-blue-100 text-blue-700 border-blue-200' },
assinado: { label: 'Assinado', class: 'bg-emerald-100 text-emerald-700 border-emerald-200' },
recusado: { label: 'Recusado', class: 'bg-rose-100 text-rose-700 border-rose-200' },
expirado: { label: 'Expirado', class: 'bg-zinc-100 text-zinc-600 border-zinc-200' }
};
return map[s] || { label: s, class: 'bg-zinc-100 text-zinc-600' };
}
function openSignFlow(sig) {
if (!sig.share_token) {
// Sem share link válido terapeuta precisa gerar um. Mostra aviso.
return;
}
router.push({ name: 'shared.document', params: { token: sig.share_token } });
}
onMounted(() => {
loadMine();
});
</script>
<template>
<div class="p-4 md:p-6">
<!-- Header -->
<div class="mb-6 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative p-6">
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-16 -right-16 h-52 w-52 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute bottom-0 left-10 h-44 w-44 rounded-full bg-emerald-400/10 blur-3xl" />
</div>
<div class="relative flex items-center gap-4">
<div class="grid h-14 w-14 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-file-edit text-2xl" />
</div>
<div class="min-w-0 flex-1">
<div class="text-2xl font-semibold leading-none">Documentos para assinatura</div>
<div class="mt-2 text-sm text-color-secondary">
Termos e documentos solicitados pelo(a) seu(sua) terapeuta. Você pode assinar pelo link enviado ou clicar em "Assinar agora".
</div>
</div>
</div>
<!-- KPIs -->
<div class="relative mt-6 grid grid-cols-2 gap-3 md:grid-cols-4">
<button
:class="['rounded-xl border p-3 text-left transition', statusFilter === 'todos' ? 'border-[var(--primary-color)] bg-[var(--primary-color)]/5' : 'border-[var(--surface-border)] hover:border-[var(--primary-color)]/40']"
@click="statusFilter = 'todos'"
>
<div class="text-[0.7rem] uppercase tracking-wide text-color-secondary">Total</div>
<div class="mt-1 text-xl font-bold">{{ counts.total }}</div>
</button>
<button
:class="['rounded-xl border p-3 text-left transition', statusFilter === 'pendentes' ? 'border-amber-400 bg-amber-50/50' : 'border-[var(--surface-border)] hover:border-amber-300']"
@click="statusFilter = 'pendentes'"
>
<div class="text-[0.7rem] uppercase tracking-wide text-color-secondary">Pendentes</div>
<div class="mt-1 text-xl font-bold text-amber-700">{{ counts.pendentes }}</div>
</button>
<button
:class="['rounded-xl border p-3 text-left transition', statusFilter === 'assinados' ? 'border-emerald-400 bg-emerald-50/50' : 'border-[var(--surface-border)] hover:border-emerald-300']"
@click="statusFilter = 'assinados'"
>
<div class="text-[0.7rem] uppercase tracking-wide text-color-secondary">Assinados</div>
<div class="mt-1 text-xl font-bold text-emerald-700">{{ counts.assinados }}</div>
</button>
<div class="rounded-xl border border-[var(--surface-border)] p-3">
<div class="text-[0.7rem] uppercase tracking-wide text-color-secondary">Recusados</div>
<div class="mt-1 text-xl font-bold text-rose-700">{{ counts.recusados }}</div>
</div>
</div>
</div>
</div>
<!-- Loading -->
<div v-if="loading && !signatures.length" class="flex flex-col items-center justify-center py-20">
<i class="pi pi-spinner pi-spin text-3xl text-color-secondary mb-3" />
<p class="text-sm text-color-secondary">Carregando seus documentos</p>
</div>
<!-- Erro -->
<div v-else-if="error" class="rounded-xl border border-rose-200 bg-rose-50/50 p-4 text-sm text-rose-700">
<i class="pi pi-exclamation-triangle mr-2" />{{ error }}
</div>
<!-- Empty -->
<div v-else-if="!filtered.length" class="flex flex-col items-center justify-center rounded-2xl border border-dashed border-[var(--surface-border)] bg-[var(--surface-card)] py-16">
<div class="mx-auto mb-4 grid h-16 w-16 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-inbox text-2xl" />
</div>
<div class="text-lg font-semibold">Nenhum documento {{ statusFilter === 'pendentes' ? 'pendente' : statusFilter === 'assinados' ? 'assinado' : '' }} no momento</div>
<div class="mt-2 text-sm text-color-secondary">Quando seu(sua) terapeuta solicitar assinatura, o documento aparecerá aqui.</div>
</div>
<!-- Lista -->
<div v-else class="space-y-3">
<div
v-for="sig in filtered"
:key="sig.signature_id"
class="overflow-hidden rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] transition hover:border-[var(--primary-color)]/30"
>
<div class="flex items-center gap-4 p-4">
<!-- Icon -->
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-lg bg-blue-50">
<i :class="sig.mime_type === 'application/pdf' ? 'pi pi-file-pdf text-red-500' : 'pi pi-file text-blue-500'" class="text-xl" />
</div>
<!-- Info -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<div class="text-sm font-semibold text-[var(--text-color)] truncate">{{ sig.nome_original || 'Documento sem nome' }}</div>
<span :class="['inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[0.65rem] font-medium', statusBadge(sig.status).class]">
{{ statusBadge(sig.status).label }}
</span>
</div>
<div class="mt-1 text-xs text-color-secondary">
Tipo: <span class="font-medium">{{ sig.tipo_documento || 'outro' }}</span>
· Solicitado em {{ fmtDate(sig.criado_em) }}
<template v-if="sig.assinado_em">
· Assinado em <span class="font-medium text-emerald-700">{{ fmtDate(sig.assinado_em) }}</span>
</template>
</div>
</div>
<!-- Ação -->
<div class="shrink-0">
<button
v-if="sig.status === 'pendente' || sig.status === 'enviado'"
:disabled="!sig.share_token"
:title="!sig.share_token ? 'Link não gerado pelo terapeuta. Solicite que ele(a) compartilhe novamente.' : ''"
class="inline-flex items-center gap-2 rounded-lg bg-[var(--primary-color)] px-3 py-2 text-xs font-semibold text-white shadow-sm transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
@click="openSignFlow(sig)"
>
<i class="pi pi-pencil" />
Assinar agora
</button>
<div v-else-if="sig.status === 'assinado'" class="text-xs text-emerald-700 font-medium flex items-center gap-1">
<i class="pi pi-check-circle" />
Concluído
</div>
<div v-else class="text-xs text-color-secondary"></div>
</div>
</div>
</div>
</div>
</div>
</template>
+227 -17
View File
@@ -9,9 +9,10 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { validateShareToken } from '@/services/DocumentShareLinks.service'
import { getSignableDocumentByToken, signByToken, hashDocument, refuseSignature } from '@/services/DocumentSignatures.service'
import { supabase } from '@/lib/supabase/client'
const route = useRoute()
@@ -21,6 +22,45 @@ const error = ref(null)
const doc = ref(null)
const previewUrl = ref('')
// Assinaturas associadas ao share link (vazio = só visualização)
const signablePayload = ref(null) // { valid, document, signatures, expira_em, usos_restantes }
const signing = ref(false)
const signError = ref('')
const signSuccess = ref(false)
const refused = ref(false)
const acceptedTerms = ref(false)
const selectedSignatureId = ref(null) // pra desambiguar multi-signatário
const pendingSignatures = computed(() => {
const list = signablePayload.value?.signatures || []
return list.filter(s => s.status === 'pendente' || s.status === 'enviado')
})
const completedSignatures = computed(() => {
const list = signablePayload.value?.signatures || []
return list.filter(s => s.status === 'assinado')
})
const hasSignatureFlow = computed(() => {
const list = signablePayload.value?.signatures || []
return list.length > 0
})
const activeSignature = computed(() => {
if (pendingSignatures.value.length === 0) return null
if (pendingSignatures.value.length === 1) return pendingSignatures.value[0]
return pendingSignatures.value.find(s => s.id === selectedSignatureId.value) || null
})
async function fetchSignedPreviewUrl(documentLike) {
const bucket = documentLike.storage_bucket || 'documents'
const { data, error: storageErr } = await supabase.storage
.from(bucket)
.createSignedUrl(documentLike.bucket_path, 300)
if (storageErr) throw storageErr
return data?.signedUrl || ''
}
onMounted(async () => {
const token = route.params.token
if (!token) {
@@ -30,23 +70,26 @@ onMounted(async () => {
}
try {
const result = await validateShareToken(token)
if (!result?.document) {
error.value = 'Este link expirou, atingiu o limite de acessos ou é inválido.'
loading.value = false
return
// 1) Tenta carregar via RPC enriquecida (inclui signatures pendentes do doc).
const payload = await getSignableDocumentByToken(token)
if (payload?.valid) {
signablePayload.value = payload
doc.value = payload.document
previewUrl.value = await fetchSignedPreviewUrl(payload.document)
// Auto-selecionar única assinatura pendente
const pending = (payload.signatures || []).filter(s => s.status === 'pendente' || s.status === 'enviado')
if (pending.length === 1) selectedSignatureId.value = pending[0].id
} else {
// Fallback: rota legacy (só visualização) usa o validateShareToken antigo
const result = await validateShareToken(token)
if (!result?.document) {
error.value = 'Este link expirou, atingiu o limite de acessos ou é inválido.'
loading.value = false
return
}
doc.value = result.document
previewUrl.value = await fetchSignedPreviewUrl(result.document)
}
doc.value = result.document
// Gerar URL assinada para download/visualizacao
const bucket = result.document.storage_bucket || 'documents'
const { data, error: storageErr } = await supabase.storage
.from(bucket)
.createSignedUrl(result.document.bucket_path, 300) // 5 min
if (storageErr) throw storageErr
previewUrl.value = data?.signedUrl || ''
} catch (e) {
error.value = e?.message || 'Erro ao acessar o documento.'
} finally {
@@ -65,6 +108,66 @@ function downloadFile() {
document.body.removeChild(a)
}
// Hash SHA-256 do PDF baixado do storage. Garante integridade do que foi
// efetivamente assinado (não cliente-side mutável).
async function computeDocHash() {
if (!previewUrl.value) return null
try {
const res = await fetch(previewUrl.value)
const buf = await res.arrayBuffer()
return await hashDocument(buf)
} catch {
return null
}
}
async function onAssinar() {
if (!acceptedTerms.value || signing.value) return
if (pendingSignatures.value.length > 1 && !selectedSignatureId.value) {
signError.value = 'Selecione qual signatário você é.'
return
}
signing.value = true
signError.value = ''
try {
const hash = await computeDocHash()
await signByToken(
route.params.token,
selectedSignatureId.value || activeSignature.value?.id || null,
hash
)
signSuccess.value = true
// Recarrega payload pra refletir novo status
const refreshed = await getSignableDocumentByToken(route.params.token)
if (refreshed?.valid) signablePayload.value = refreshed
} catch (e) {
signError.value = e?.message || 'Falha ao registrar a assinatura.'
} finally {
signing.value = false
}
}
async function onRecusar() {
if (signing.value) return
if (!activeSignature.value?.id) {
signError.value = 'Não foi possível identificar a assinatura.'
return
}
if (!confirm('Deseja realmente recusar a assinatura deste documento? Esta ação é registrada e o(a) terapeuta será notificado(a).')) return
signing.value = true
signError.value = ''
try {
await refuseSignature(activeSignature.value.id)
refused.value = true
const refreshed = await getSignableDocumentByToken(route.params.token)
if (refreshed?.valid) signablePayload.value = refreshed
} catch (e) {
signError.value = e?.message || 'Falha ao recusar a assinatura.'
} finally {
signing.value = false
}
}
const isPdf = () => doc.value?.mime_type === 'application/pdf'
const isImage = () => String(doc.value?.mime_type || '').startsWith('image/')
</script>
@@ -128,6 +231,113 @@ const isImage = () => String(doc.value?.mime_type || '').startsWith('image/')
</div>
</div>
<!-- Painel de assinatura ( aparece se signatures associadas) -->
<div v-if="hasSignatureFlow" class="border-t border-gray-200 bg-blue-50/30 p-5">
<!-- Estado: assinou nesta sessão -->
<div v-if="signSuccess" class="flex items-start gap-3 rounded-lg border border-emerald-200 bg-emerald-50 p-4">
<i class="pi pi-check-circle text-2xl text-emerald-600 mt-0.5" />
<div>
<div class="text-sm font-semibold text-emerald-800">Assinatura registrada com sucesso!</div>
<div class="mt-1 text-xs text-emerald-700">Sua assinatura foi registrada com IP, data/hora e hash do documento. O(a) terapeuta foi notificado(a).</div>
</div>
</div>
<!-- Estado: recusou -->
<div v-else-if="refused" class="flex items-start gap-3 rounded-lg border border-zinc-200 bg-zinc-50 p-4">
<i class="pi pi-times-circle text-2xl text-zinc-600 mt-0.5" />
<div>
<div class="text-sm font-semibold text-zinc-800">Assinatura recusada</div>
<div class="mt-1 text-xs text-zinc-700">Sua recusa foi registrada. O(a) terapeuta será informado(a) e poderá entrar em contato.</div>
</div>
</div>
<!-- Estado: tem pendência -->
<div v-else-if="pendingSignatures.length > 0">
<div class="mb-3 flex items-center gap-2">
<i class="pi pi-pencil text-blue-600" />
<div class="text-sm font-semibold text-gray-800">Assinatura solicitada</div>
</div>
<!-- Seleção de signatário (multi) -->
<div v-if="pendingSignatures.length > 1" class="mb-3">
<label class="block text-xs font-medium text-gray-700 mb-1">Selecione quem você é:</label>
<select
v-model="selectedSignatureId"
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
>
<option :value="null"> escolha </option>
<option
v-for="sig in pendingSignatures"
:key="sig.id"
:value="sig.id"
>
{{ sig.signatario_nome || sig.signatario_email || sig.signatario_tipo }}
</option>
</select>
</div>
<!-- Aviso LGPD/CFP -->
<div class="mb-3 rounded-md border border-blue-200 bg-blue-50 p-3 text-xs text-blue-800">
<i class="pi pi-info-circle mr-1" />
Ao assinar, ficarão registrados: data/hora, seu endereço IP, navegador e hash criptográfico (SHA-256) do documento. Esses dados garantem integridade e autenticidade conforme a LGPD (Lei 13.709/2018) e o Código de Ética do Psicólogo.
</div>
<!-- Checkbox aceite -->
<label class="flex items-start gap-2 cursor-pointer mb-3">
<input
v-model="acceptedTerms"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-gray-700">
Li e compreendi o documento acima e <strong>concordo em assiná-lo eletronicamente</strong>.
</span>
</label>
<!-- Erro -->
<div v-if="signError" class="mb-3 rounded-md border border-rose-200 bg-rose-50 p-2 text-xs text-rose-700">
<i class="pi pi-exclamation-triangle mr-1" />{{ signError }}
</div>
<!-- Ações -->
<div class="flex flex-col gap-2 sm:flex-row sm:justify-end">
<button
:disabled="signing"
class="rounded-lg border border-rose-300 bg-white px-4 py-2 text-sm font-medium text-rose-700 transition hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-50"
@click="onRecusar"
>
<i class="pi pi-times mr-1" />
Recusar
</button>
<button
:disabled="!acceptedTerms || signing"
class="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-600 px-5 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
@click="onAssinar"
>
<i v-if="signing" class="pi pi-spinner pi-spin" />
<i v-else class="pi pi-check" />
{{ signing ? 'Registrando…' : 'Assinar agora' }}
</button>
</div>
</div>
<!-- Estado: tudo assinado -->
<div v-else class="flex items-start gap-3 rounded-lg border border-emerald-200 bg-emerald-50 p-4">
<i class="pi pi-check-circle text-2xl text-emerald-600 mt-0.5" />
<div>
<div class="text-sm font-semibold text-emerald-800">Documento assinado</div>
<div class="mt-1 text-xs text-emerald-700">
<template v-if="completedSignatures.length">
Última assinatura registrada em {{ new Date(completedSignatures[completedSignatures.length - 1].assinado_em).toLocaleString('pt-BR') }}.
</template>
<template v-else>
Sem assinaturas pendentes para este documento.
</template>
</div>
</div>
</div>
</div>
<!-- Footer -->
<div class="p-3 text-center text-xs text-gray-400 border-t border-gray-200">
Compartilhado com segurança via AgênciaPSI
+96 -1
View File
@@ -16,9 +16,13 @@
-->
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useLayout } from '@/layout/composables/layout';
import { exportSessionsToPDF, exportSessionsToXLSX, exportSessionsToCSV } from '@/services/reportExport.service';
const toast = useToast();
const { layoutConfig, isDarkTheme } = useLayout();
const tenantStore = useTenantStore();
@@ -227,6 +231,68 @@ function patientName(s) {
return s.patients?.nome_completo || '—';
}
// Export PDF / Excel / CSV
const exportingPdf = ref(false);
const exportingXlsx = ref(false);
function buildExportParams() {
const period = PERIODS.find(p => p.value === selectedPeriod.value)?.label || '';
// Normaliza patients.nome_completo pra paciente_nome (RelatoriosPage usa nested patients(...))
const normalized = sessionsFiltradas.value.map(s => ({
...s,
paciente_nome: s.patients?.nome_completo || '—'
}));
return {
title: 'Relatório de Sessões',
subtitle: period,
sessions: normalized,
kpis: [
{ label: 'Total', value: total.value },
{ label: 'Realizadas', value: realizadas.value },
{ label: 'Faltas', value: faltas.value },
{ label: 'Canceladas', value: canceladas.value },
{ label: 'Taxa', value: taxaRealizacao.value != null ? `${taxaRealizacao.value}%` : '—' }
],
tenantName: tenantStore.activeTenantName || tenantStore.tenant?.name || '',
terapeutaNome: tenantStore.user?.full_name || tenantStore.user?.email || ''
};
}
async function exportPdf() {
if (exportingPdf.value) return;
exportingPdf.value = true;
try {
const file = await exportSessionsToPDF(buildExportParams());
toast.add({ severity: 'success', summary: 'PDF gerado', detail: file, life: 2500 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao gerar PDF', detail: e?.message || '', life: 4500 });
} finally {
exportingPdf.value = false;
}
}
async function exportXlsx() {
if (exportingXlsx.value) return;
exportingXlsx.value = true;
try {
const file = await exportSessionsToXLSX(buildExportParams());
toast.add({ severity: 'success', summary: 'Excel gerado', detail: file, life: 2500 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao gerar Excel', detail: e?.message || '', life: 4500 });
} finally {
exportingXlsx.value = false;
}
}
function exportCsv() {
try {
const file = exportSessionsToCSV(buildExportParams());
toast.add({ severity: 'success', summary: 'CSV gerado', detail: file, life: 2500 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao gerar CSV', detail: e?.message || '', life: 4500 });
}
}
// Watch & mount
watch(selectedPeriod, () => {
filtroTabela.value = null;
@@ -270,8 +336,37 @@ onMounted(loadSessions);
<SelectButton v-model="selectedPeriod" :options="PERIODS" option-label="label" option-value="value" :allow-empty="false" size="small" />
</div>
<!-- Refresh -->
<!-- Refresh + Exports -->
<div class="flex items-center gap-1.5 shrink-0 ml-auto">
<Button
icon="pi pi-file-pdf"
severity="secondary"
outlined
class="h-9 w-9 rounded-full"
:loading="exportingPdf"
:disabled="!hasLoaded || loading || total === 0"
v-tooltip.bottom="'Exportar PDF'"
@click="exportPdf"
/>
<Button
icon="pi pi-file-excel"
severity="secondary"
outlined
class="h-9 w-9 rounded-full"
:loading="exportingXlsx"
:disabled="!hasLoaded || loading || total === 0"
v-tooltip.bottom="'Exportar Excel (.xlsx)'"
@click="exportXlsx"
/>
<Button
icon="pi pi-table"
severity="secondary"
outlined
class="h-9 w-9 rounded-full"
:disabled="!hasLoaded || loading || total === 0"
v-tooltip.bottom="'Exportar CSV'"
@click="exportCsv"
/>
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Recarregar" @click="loadSessions" />
</div>
</div>