36 Commits

Author SHA1 Message Date
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
27 changed files with 3505 additions and 454 deletions
+54
View File
@@ -1508,3 +1508,57 @@ TOTAL DA SESSAO (24/05 - 25/05, ~24 commits):
- Agenda decomposicao A+B1+B2: -991L em useMelissaAgenda (~33%)
- Agenda Fases C+D: Rail+Clinica adotam billing core
- useAgendaStatusChange composable novo
## [2026-05-21 23:00] session | Melissa Fase 2 UX iter + bug isFinite(null)
Touched: feedback_isfinite_strict, feedback_teleport_body_styles
Detalhes:
Sessao de testes manuais Fase 2 (templates + paciente.documentos).
4 ajustes UX + 1 bug funcional resolvido. 5 commits, 0 push (SSL
self-signed Gitea — user faz manual amanha).
1) MelissaPatientDocuments (4e1ebeb, 6c39c58):
Aba Documentos no /melissa/paciente?id=X foi convertida de embed
<DocumentsListPage> pra pagina nativa 2-col Melissa. Drawer mobile
bugava (transform/filter em ancestrais trapando position:fixed).
Fix:
- <Teleport to="body"> no drawer + backdrop pra escapar stacking
- styles do drawer movidos pra <style> nao-scoped (teleport perde
data-v attrs do scoped)
- wrapper teleportado recebe class "win11-root" pra herdar vars
--m-* (definidas nesse escopo no MelissaLayout)
- cascata --mpd-bg/border/text: --m-* -> --p-* -> hardcoded
2) DocumentGenerateDialog (61bb0d9, 512bcc9):
Inputs trocados pra FloatLabel variant="on". Adicionado map de
ORIGEM dos campos (TEMPLATE_VARIABLES.source) — hint embaixo de
cada campo vazio explica onde cadastrar (ex: "Perfil -> Registro
Profissional"). Banner verde/amber no topo conta preenchidos.
3) Bug critico (4f05c2c) — RAIZ do "campos vem vazio mesmo com
profile preenchido":
loadAllVariables crashava com TypeError "Cannot read properties
of null (reading toFixed)" quando NAO havia sessao vinculada
(agendaEventoId=null) E sem extras.valor. Toda a Promise
estourava, variables zerava.
Causa: isFinite(null) global retorna TRUE (Number(null)===0),
entrava no branch valorNum.toFixed e crashava.
Fix: trocar por Number.isFinite (strict, nao coerce).
Salvo como memoria feedback_isfinite_strict.
PROXIMA SESSAO (retomar amanha 22/05):
- Continuar Fase 2: 2.7-2.9 (gerar PDF dentro da aba Documentos
do paciente, conferir vars CRP/UF preenchem, doc aparece como
tipo_documento='outro')
- Gerar JSON docs Fase 2 (#6 + templates page)
- Fase 3: Portal assinatura #7
- Fase 4: Recibo profissional #14 testes
- Fase 5: Relatorios export #13
- Fase 6: C12 UX iter (deferred 20/05)
- Fase 7: Regressao Agenda C7-C13
PUSH PENDENTE: 35 commits ahead of origin/main; SSL self-signed
do Gitea exige `git -c http.sslVerify=false push origin main`
+ credenciais (user faz manual).
@@ -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;
+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;
File diff suppressed because one or more lines are too long
+45 -9
View File
@@ -17,31 +17,61 @@
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
const isOnline = ref(true); // começa como true; detecta em onMounted
// ── Estado ────────────────────────────────────────────────────
// Começa otimista (true) — só marca offline com confirmação dupla.
const isOnline = ref(true);
const wasOffline = ref(false);
const showReconnected = ref(false);
let pollTimer = null;
let reconnectedTimer = null;
let consecutiveFailures = 0;
// ── Detecção real: tenta buscar um recurso minúsculo ──────────
// Em DEV, ignora completamente o polling: Vite HMR + dev server podem
// disparar falhas pontuais que geram falso positivo constante. Em DEV,
// só confia em navigator.onLine + eventos nativos (mais conservador).
const IS_DEV = import.meta.env?.DEV === true;
// Tolerância: precisa N falhas seguidas pra considerar offline. Evita
// falso positivo de slow request / HMR rebuild / network blip.
const FAILURE_THRESHOLD = 2;
const POLL_INTERVAL = IS_DEV ? 60_000 : 30_000;
const FETCH_TIMEOUT = 8_000;
// ── Detecção: navigator.onLine primeiro, fetch como confirmação ──
//
// navigator.onLine é a fonte autoritativa do browser. Se for true,
// quase certo que tem rede física. Se for false, com certeza offline.
// O fetch só serve pra detectar "rede funciona mas servidor parado".
async function checkConnectivity() {
// 1) Browser offline = confia direto, sem fetch
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
consecutiveFailures = FAILURE_THRESHOLD;
setOffline();
return;
}
// 2) Browser online — confirma com HEAD no favicon (rápido, cacheável)
try {
// favicon do próprio app (cache busted) — não depende de rede externa
await fetch('/favicon.ico?_t=' + Date.now(), {
method: 'HEAD',
cache: 'no-store',
signal: AbortSignal.timeout(4000)
signal: AbortSignal.timeout(FETCH_TIMEOUT)
});
consecutiveFailures = 0;
setOnline();
} catch {
setOffline();
consecutiveFailures++;
// Só marca offline após N falhas consecutivas — evita falso positivo
// de slow request, HMR rebuild, transient blip.
if (consecutiveFailures >= FAILURE_THRESHOLD) {
setOffline();
}
}
}
function setOnline() {
if (!isOnline.value && wasOffline.value) {
// acabou de reconectar
showReconnected.value = true;
if (reconnectedTimer) clearTimeout(reconnectedTimer);
reconnectedTimer = setTimeout(() => {
@@ -59,19 +89,25 @@ function setOffline() {
}
// ── Eventos nativos do browser ────────────────────────────────
// navigator.onLine + offline/online events são SUPER confiáveis pra
// estado real (sem rede física, wifi caiu, etc). Outros falsos
// positivos vinham só do fetch agressivo.
function onBrowserOffline() {
consecutiveFailures = FAILURE_THRESHOLD;
setOffline();
}
function onBrowserOnline() {
consecutiveFailures = 0;
checkConnectivity();
} // confirma antes de marcar online
}
onMounted(() => {
window.addEventListener('offline', onBrowserOffline);
window.addEventListener('online', onBrowserOnline);
// Polling a cada 10 s — captura quedas que não disparam evento
pollTimer = setInterval(checkConnectivity, 10_000);
// Polling defensivo — captura quedas que não disparam evento
// (raras, ex: DNS travado em wifi público).
pollTimer = setInterval(checkConnectivity, POLL_INTERVAL);
// Verifica estado atual ao montar (útil se já começou offline)
checkConnectivity();
+16 -4
View File
@@ -197,8 +197,17 @@ function generateUser() {
});
}
function patientsListRoute() {
// Rota de destino do "Salvar e ver paciente". Em melissa, prefere a
// view individual do paciente recém-criado (id vem de data.id no
// emit('created')); fallback pra lista.
function patientViewRoute(patientId) {
const p = String(route.path || '');
if (p.startsWith('/melissa') && patientId) {
return { path: '/melissa/paciente', query: { id: String(patientId) } };
}
if (p.startsWith('/melissa')) {
return '/melissa/pacientes';
}
return p.startsWith('/therapist') ? '/therapist/patients' : '/admin/pacientes';
}
@@ -252,7 +261,10 @@ async function submit(mode = 'only') {
emit('created', data);
if (props.closeOnCreated) close();
if (mode === 'view') await router.push(patientsListRoute());
if (mode === 'view') {
const pid = data?.id || null;
await router.push(patientViewRoute(pid));
}
} catch (err) {
const msg = err?.message || err?.details || 'Não foi possível criar o paciente.';
errorMsg.value = msg;
@@ -334,10 +346,10 @@ async function submit(mode = 'only') {
<!-- Na rota de pacientes OU em fluxo (hideViewListButton): "Salvar" / "Salvar e fechar" -->
<Button v-if="isOnPatientsPage" label="Salvar" :loading="saving" :disabled="saving" @click="submit('only')" />
<Button v-else-if="hideViewListButton" label="Salvar e fechar" :loading="saving" :disabled="saving" @click="submit('only')" />
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver pacientes" -->
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver paciente" -->
<template v-else>
<Button label="Salvar e fechar" severity="secondary" outlined :loading="saving" :disabled="saving" @click="submit('only')" />
<Button label="Salvar e ver pacientes" :loading="saving" :disabled="saving" @click="submit('view')" />
<Button label="Salvar e ver paciente" :loading="saving" :disabled="saving" @click="submit('view')" />
</template>
</div>
</template>
+1 -1
View File
@@ -34,7 +34,7 @@ export const PAGES = [
{ id: 'p_t_medicos', label: 'Médicos referenciadores', icon: 'pi pi-user-edit', sublabel: 'Médicos que encaminham pacientes', path: '/therapist/patients/medicos', roles: ['therapist'], keywords: kw('medicos','encaminhadores','referenciadores','indicacao') },
{ id: 'p_t_link_externo', label: 'Link de cadastro externo', icon: 'pi pi-link', sublabel: 'Link público pra pacientes', path: '/therapist/patients/link-externo', roles: ['therapist'], keywords: kw('link','externo','publico','cadastro paciente','convite') },
{ id: 'p_t_cad_recebidos', label: 'Cadastros recebidos', icon: 'pi pi-inbox', sublabel: 'Pacientes aguardando aceite', path: '/therapist/patients/cadastro/recebidos', roles: ['therapist'], keywords: kw('recebidos','pendentes','aceitar','intake','novos') },
{ id: 'p_t_doc_templates', label: 'Templates de documentos', icon: 'pi pi-file-edit', sublabel: 'Modelos reutilizáveis', path: '/therapist/documents/templates', roles: ['therapist'], keywords: kw('templates','modelos','contratos','documentos') },
{ id: 'p_cfg_doc_templates', label: 'Modelos de documentos', icon: 'pi pi-file-edit', sublabel: 'Configurações → Documentos', path: '/configuracoes/documentos/templates', roles: ['therapist','admin'], keywords: kw('templates','modelos','contratos','documentos','recibo','atestado','laudo','tcle','lgpd','consent') },
{ id: 'p_t_online_sched', label: 'Agendamento online', icon: 'pi pi-globe', sublabel: 'Página pública de agendamento', path: '/therapist/online-scheduling', roles: ['therapist'], keywords: kw('online','publico','agendar','landing','pagina','site') },
{ id: 'p_t_ag_recebidos', label: 'Agendamentos recebidos', icon: 'pi pi-calendar-plus',sublabel: 'Solicitações da agenda pública', path: '/therapist/agendamentos-recebidos', roles: ['therapist'], keywords: kw('solicitacoes','recebidos','publico','pedidos') },
{ id: 'p_t_fin_lanc', label: 'Lançamentos financeiros', icon: 'pi pi-list', sublabel: 'Entradas e saídas', path: '/therapist/financeiro/lancamentos', roles: ['therapist'], keywords: kw('lancamentos','entradas','saidas','fluxo de caixa','receitas','despesas') },
+21 -4
View File
@@ -117,14 +117,14 @@ function buildConfig() {
];
// Toolbar completa para o corpo do e-mail
// Botões hr (linha horizontal), eraser (apagar formatação) e source (HTML)
// foram removidos — não funcionavam de forma esperada.
const bodyButtons = [
'bold', 'italic', 'underline', 'strikethrough', '|',
'ul', 'ol', '|',
'font', 'fontsize', 'brush', 'paragraph', '|',
'align', '|',
'link', 'table', '|',
'hr', 'eraser', '|',
'source'
'link', 'table'
];
return {
@@ -194,7 +194,24 @@ watch(
// ── API exposta ───────────────────────────────────────────────
defineExpose({
insertHTML: (html) => jodit?.selection.insertHTML(html)
insertHTML: (html) => jodit?.selection.insertHTML(html),
// Salva markers da seleção atual antes do foco sair do editor
// (ex: usuário abre drawer e perde o cursor). Retorna o array de
// markers que pode ser passado pra restoreSelection depois.
saveSelection: () => {
if (!jodit) return null;
try { return jodit.selection.save(); }
catch { return null; }
},
// Restaura selection a partir dos markers salvos. Re-foca o editor.
restoreSelection: (markers) => {
if (!jodit) return;
try {
jodit.focus();
if (markers) jodit.selection.restore(markers);
} catch { /* silencioso */ }
},
focus: () => jodit?.focus()
});
</script>
+18 -5
View File
@@ -65,11 +65,22 @@ const router = useRouter();
const isOnPatientsPage = computed(() => {
const p = String(route.path || '');
return p.includes('/patients') || p.includes('/pacientes');
// /melissa/paciente (singular — prontuário) é página de paciente.
// /melissa/pacientes (plural — lista) também.
return p.includes('/patients') || p.includes('/pacientes') || p.startsWith('/melissa/paciente');
});
function patientsListRoute() {
// Rota de destino quando o usuário pede "Salvar e ver paciente":
// — no Melissa, abre o prontuário do paciente (singular, via query id)
// — no Therapist/Admin, volta pra lista (não há rota dedicada de view).
function patientViewRoute(patientId) {
const p = String(route.path || '');
if (p.startsWith('/melissa') && patientId) {
return { path: '/melissa/paciente', query: { id: String(patientId) } };
}
if (p.startsWith('/melissa')) {
return '/melissa/pacientes';
}
return p.startsWith('/therapist') ? '/therapist/patients' : '/admin/pacientes';
}
@@ -82,7 +93,9 @@ async function onCreated(data) {
isOpen.value = false;
emit('created', data);
if (pendingMode.value === 'view') {
await router.push(patientsListRoute());
// data.id vem do PatientsCadastroPage (criação ou edição)
const pid = data?.id || props.patientId || null;
await router.push(patientViewRoute(pid));
}
}
</script>
@@ -197,10 +210,10 @@ async function onCreated(data) {
<!-- Na rota de pacientes OU em fluxo (hideViewListButton): um botao -->
<Button v-if="isOnPatientsPage" label="Salvar" :loading="!!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
<Button v-else-if="hideViewListButton" label="Salvar e fechar" :loading="!!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver pacientes" -->
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver paciente" -->
<template v-else>
<Button label="Salvar e fechar" severity="secondary" outlined :loading="pendingMode === 'only' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
<Button label="Salvar e ver pacientes" :loading="pendingMode === 'view' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('view')" />
<Button label="Salvar e ver paciente" :loading="pendingMode === 'view' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('view')" />
</template>
</div>
</template>
@@ -79,11 +79,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) {
@@ -192,17 +200,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>
@@ -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;
+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',
+208 -108
View File
@@ -14,7 +14,7 @@
* Quando promover pra produção: trocar a busca por chamada à RPC
* `search_global` + manter a mesma estrutura de panel/items.
*/
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useRecentPatients } from '@/composables/useRecentPatients';
@@ -79,6 +79,9 @@ const filteredAtalhos = computed(() => {
// Pacientes combina RPC (autoritativo, todos os pacientes) com props (preview de hoje).
// RPC tem prioridade; props complementa quando RPC ainda não trouxe nada.
//
// Shape do RPC search_global (patients): { id, label, sublabel, avatar_url, deeplink, score }
// label = nome_completo; sublabel = email_principal ou telefone.
const filteredPacientes = computed(() => {
const q = normalize(query.value);
if (q.length < 2) return [];
@@ -87,15 +90,16 @@ const filteredPacientes = computed(() => {
if (rpc.length) {
return rpc.slice(0, 5).map(p => ({
id: p.id,
nome: p.nome_completo || p.nome_social || p.nome || '(sem nome)',
email: p.email,
telefone: p.telefone
nome: p.label || '(sem nome)',
sub: p.sublabel || '',
avatar_url: p.avatar_url || null
}));
}
// Fallback client-side
// Fallback client-side (props.pacientes vem do MelissaLayout shape diferente)
return props.pacientes
.filter((p) => normalize(p.nome).includes(q))
.slice(0, 5);
.slice(0, 5)
.map(p => ({ id: p.id, nome: p.nome, sub: '', avatar_url: null }));
});
const filteredEventos = computed(() => {
@@ -139,9 +143,17 @@ function selectEntry(entry) {
else if (entry.group === 'pacientes') emit('paciente', entry.item);
else if (entry.group === 'recent') emit('paciente', { id: entry.item.id, nome: entry.item.nome, ...entry.item.extras });
else if (entry.group === 'eventos') emit('evento', entry.item);
else if (entry.group === 'rpc-appointments') emit('evento', entry.item);
else if (entry.group === 'rpc-documents') emit('documento', entry.item);
else if (entry.group === 'rpc-intakes') emit('intake', entry.item);
else if (entry.group === 'rpc-appointments') {
// Sessão da RPC: deeplink pra agenda com evento focado
emit('evento', { id: entry.item.id, deeplink: entry.item.deeplink });
} else if (entry.group === 'rpc-documents') {
// Documento da RPC: extrai patient_id da deeplink se possível
const dl = entry.item.deeplink || '';
const m = dl.match(/patients\/([0-9a-f-]+)/i);
emit('documento', { id: entry.item.id, patient_id: m?.[1] || null, label: entry.item.label });
} else if (entry.group === 'rpc-intakes') {
emit('intake', entry.item);
}
closePanel();
}
@@ -149,22 +161,15 @@ 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);
@@ -174,19 +179,15 @@ function onKeydown(e) {
} 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();
}
// 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();
}
}
@@ -234,11 +235,9 @@ watch(query, (v) => {
});
onMounted(() => {
document.addEventListener('mousedown', onClickOutside);
window.addEventListener('keydown', onGlobalKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onClickOutside);
window.removeEventListener('keydown', onGlobalKeydown);
if (debounceT) clearTimeout(debounceT);
});
@@ -246,22 +245,45 @@ onBeforeUnmount(() => {
<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"
@@ -320,10 +342,10 @@ onBeforeUnmount(() => {
@click="selectEntry({ group: 'pacientes', item: p })"
@mouseenter="activeIndex = findFlatIndex('pacientes', i)"
>
<span class="mb-item__icon"><i class="pi pi-user" /></span>
<span class="mb-item__icon mb-item__icon--patient"><i class="pi pi-user" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ p.nome }}</span>
<span class="mb-item__sub">Abrir prontuário</span>
<span class="mb-item__sub">{{ p.sub || 'Abrir prontuário' }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
@@ -354,7 +376,10 @@ onBeforeUnmount(() => {
</button>
</div>
<!-- RPC: Sessões/agendamentos (qualquer data) -->
<!-- RPC: Sessões/agendamentos (qualquer data)
RPC retorna { id, label, sublabel, deeplink }. Sublabel ja vem
com "Paciente · dd/mm/yyyy HH:MM". Cor do icone = cor de sessao
(indigo-500, igual ao pickColor() padrao). -->
<div v-if="rpcAppointments.length" class="mb-group">
<div class="mb-group__title">Sessões</div>
<button
@@ -365,10 +390,10 @@ onBeforeUnmount(() => {
@click="selectEntry({ group: 'rpc-appointments', item: e })"
@mouseenter="activeIndex = findFlatIndex('rpc-appointments', i)"
>
<span class="mb-item__icon"><i class="pi pi-calendar" /></span>
<span class="mb-item__icon mb-item__icon--sessao"><i class="pi pi-calendar" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ e.paciente_nome || e.title || 'Sessão' }}</span>
<span class="mb-item__sub">{{ e.inicio_em ? new Date(e.inicio_em).toLocaleDateString('pt-BR') + ' ' + new Date(e.inicio_em).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : 'Sem data' }}</span>
<span class="mb-item__label">{{ e.label || 'Sessão' }}</span>
<span class="mb-item__sub">{{ e.sublabel || 'Sem detalhes' }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
@@ -385,10 +410,10 @@ onBeforeUnmount(() => {
@click="selectEntry({ group: 'rpc-documents', item: d })"
@mouseenter="activeIndex = findFlatIndex('rpc-documents', i)"
>
<span class="mb-item__icon"><i class="pi pi-file" /></span>
<span class="mb-item__icon mb-item__icon--doc"><i class="pi pi-file" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ d.nome_original || 'Documento' }}</span>
<span class="mb-item__sub">{{ d.paciente_nome ? `${d.paciente_nome} · ` : '' }}{{ d.tipo_documento || 'outro' }}</span>
<span class="mb-item__label">{{ d.label || 'Documento' }}</span>
<span class="mb-item__sub">{{ d.sublabel || '' }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
@@ -405,16 +430,16 @@ onBeforeUnmount(() => {
@click="selectEntry({ group: 'rpc-intakes', item: r })"
@mouseenter="activeIndex = findFlatIndex('rpc-intakes', i)"
>
<span class="mb-item__icon"><i class="pi pi-inbox" /></span>
<span class="mb-item__icon mb-item__icon--intake"><i class="pi pi-inbox" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ r.nome_completo || 'Cadastro' }}</span>
<span class="mb-item__sub">{{ r.created_at ? new Date(r.created_at).toLocaleDateString('pt-BR') : '' }}</span>
<span class="mb-item__label">{{ r.label || 'Cadastro' }}</span>
<span class="mb-item__sub">{{ r.sublabel || '' }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
</div>
</Transition>
</Dialog>
</div>
</template>
@@ -423,16 +448,17 @@ onBeforeUnmount(() => {
position: relative;
width: 100%;
max-width: 560px;
/* Só horizontal — top/bottom ficam livres pro parent (mt-X do Tailwind) */
margin-left: auto;
margin-right: auto;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
/* ─── Trigger no dock (visual de input, mas é botão que abre Dialog) ─── */
.mb-field {
position: relative;
display: flex;
align-items: center;
width: 100%;
background: var(--m-bg-soft);
backdrop-filter: blur(20px) saturate(140%);
-webkit-backdrop-filter: blur(20px) saturate(140%);
@@ -440,11 +466,12 @@ onBeforeUnmount(() => {
border-radius: 12px;
padding: 0 14px;
height: 44px;
cursor: pointer;
text-align: left;
transition: background-color 160ms ease, border-color 160ms ease;
}
.mb-field:focus-within {
.mb-field:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mb-field__icon {
color: var(--m-text-muted);
@@ -452,18 +479,15 @@ onBeforeUnmount(() => {
margin-right: 10px;
flex-shrink: 0;
}
.mb-field__input {
.mb-field__placeholder {
flex: 1;
background: transparent;
border: none;
outline: none;
color: white;
color: var(--m-text-muted);
font-size: 0.9rem;
font-family: inherit;
min-width: 0;
}
.mb-field__input::placeholder {
color: var(--m-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mb-field__kbd {
color: var(--m-text-muted);
@@ -478,49 +502,109 @@ onBeforeUnmount(() => {
letter-spacing: 0.05em;
}
/* ─── Dialog Spotlight (PrimeVue Dialog customizado) ─── */
:global(.mb-dialog__mask) {
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
background: rgba(0, 0, 0, 0.55) !important;
}
:global(.mb-dialog) {
border-radius: 14px !important;
overflow: hidden;
/* Posiciona mais alto que o centro (estilo Spotlight) */
margin-top: 10vh !important;
align-self: flex-start;
}
:global(.mb-dialog .mb-dialog__content) {
padding: 0 !important;
background: var(--surface-card) !important;
border-radius: 14px !important;
overflow: hidden;
max-height: 70vh;
display: flex;
flex-direction: column;
}
.mb-dialog__field {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-card);
flex-shrink: 0;
}
.mb-dialog__field-icon {
color: var(--text-color-secondary);
font-size: 1.05rem;
flex-shrink: 0;
}
.mb-dialog__input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text-color);
font-size: 1.05rem;
font-family: inherit;
min-width: 0;
}
.mb-dialog__input::placeholder {
color: var(--text-color-secondary);
opacity: 0.7;
}
.mb-dialog__esc {
color: var(--text-color-secondary);
font-size: 0.65rem;
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
background: var(--surface-ground);
border: 1px solid var(--surface-border);
flex-shrink: 0;
letter-spacing: 0.05em;
opacity: 0.8;
}
/* Panel agora vive DENTRO do Dialog (não mais absolute). Scroll interno
resolve o bug de overflow o conteúdo nunca passa do bottom porque
o Dialog tem max-height e o panel é flex:1. */
.mb-panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 30;
max-height: 60vh;
flex: 1 1 auto;
overflow-y: auto;
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border: 1px solid var(--m-border-strong);
border-radius: 12px;
overflow-x: hidden;
padding: 6px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
background: var(--surface-card);
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
scrollbar-color: var(--surface-border) transparent;
min-height: 0; /* permite shrink no flex */
}
.mb-panel::-webkit-scrollbar { width: 6px; }
.mb-panel::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
background: var(--surface-border);
border-radius: 3px;
}
.mb-empty {
padding: 18px 14px;
text-align: center;
color: var(--m-text-muted);
color: var(--text-color-secondary);
font-size: 0.85rem;
}
.mb-group + .mb-group {
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--m-border);
border-top: 1px solid var(--surface-border);
}
.mb-group__title {
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--m-text-faint);
font-size: 0.6rem;
font-weight: 600;
color: var(--text-color-secondary);
font-size: 0.62rem;
font-weight: 700;
padding: 8px 10px 4px;
opacity: 0.75;
}
.mb-item {
@@ -528,11 +612,11 @@ onBeforeUnmount(() => {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
padding: 9px 10px;
background: transparent;
border: none;
border-radius: 8px;
color: white;
color: var(--text-color);
text-align: left;
cursor: pointer;
font-family: inherit;
@@ -540,53 +624,69 @@ 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; }
.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>
+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; }
+6 -11
View File
@@ -24,7 +24,7 @@ import { useToast } from 'primevue/usetoast';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import { MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue';
import DocumentsListPage from '@/features/documents/DocumentsListPage.vue';
import MelissaPatientDocuments from '@/layout/melissa/MelissaPatientDocuments.vue';
import PatientConversationsTab from '@/features/patients/prontuario/PatientConversationsTab.vue';
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
import { usePatientDetail } from '@/features/patients/composables/usePatientDetail';
@@ -2092,16 +2092,11 @@ onBeforeUnmount(() => {
</article>
</div>
<!-- DocumentsListPage embedded (ja vem com upload/preview/lista) -->
<section class="mpa-w mpa-embed">
<div class="mpa-w__body mpa-embed__body">
<DocumentsListPage
:patient-id="patientId"
:patient-name="nomeCompleto || ''"
embedded
/>
</div>
</section>
<!-- Documentos nativos Melissa (2-col com tipos na sidebar) -->
<MelissaPatientDocuments
:patient-id="patientId"
:patient-name="nomeCompleto || ''"
/>
</template>
</div>
@@ -0,0 +1,742 @@
<!--
|--------------------------------------------------------------------------
| 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('');
// 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) {
selectedDoc.value = doc;
// Reusa preview dialog em modo "ver" edit completo só via DocumentsListPage
onPreview(doc);
}
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"
/>
<DocumentGenerateDialog
v-if="patientId"
v-model:visible="generateDlg"
:patient-id="patientId"
:patient-name="patientName"
@generated="onGenerated"
/>
<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>
@@ -79,6 +79,15 @@ export const MELISSA_CONFIG_GRUPOS = [
{ key: 'cfg-email-templates', label: 'Templates de E-mail', desc: 'Personalize os e-mails enviados aos pacientes.', icon: 'pi pi-envelope' }
]
},
{
key: 'documentos',
label: 'Documentos',
desc: 'Modelos, assinaturas e configurações da geração de documentos.',
icon: 'pi pi-file',
items: [
{ key: 'documentos-templates', label: 'Modelos de documentos', desc: 'Cadastre e edite templates de recibos, atestados, laudos, TCLE, LGPD e mais.', icon: 'pi pi-file-edit' }
]
},
{
key: 'plataforma',
label: 'Plataforma',
+2 -2
View File
@@ -73,8 +73,8 @@ export default function adminMenu(ctx = {}) {
{ label: 'Tags', icon: 'pi pi-fw pi-tags', to: { name: 'admin-pacientes-tags' } },
{ label: 'Médicos & Referências', icon: 'pi pi-fw pi-heart', to: { name: 'admin-pacientes-medicos' } },
{ label: 'Link Externo', icon: 'pi pi-fw pi-link', to: { name: 'admin-pacientes-link-externo' } },
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: { name: 'admin-pacientes-recebidos' }, badgeKey: 'cadastrosRecebidos' },
{ label: 'Templates de Documentos', icon: 'pi pi-fw pi-file-edit', to: { name: 'admin-documents-templates' }, feature: 'documents.templates', proBadge: true }
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: { name: 'admin-pacientes-recebidos' }, badgeKey: 'cadastrosRecebidos' }
// "Templates de Documentos" movido pra Configurações
]
},
+1 -1
View File
@@ -45,7 +45,7 @@ export default [
{ label: 'Tags', icon: 'pi pi-tags', to: '/therapist/patients/tags' },
{ label: 'Médicos & Referências', icon: 'pi pi-heart', to: '/therapist/patients/medicos' },
{ label: 'Documentos', icon: 'pi pi-file', to: '/therapist/documents', feature: 'documents.upload' },
{ label: 'Templates', icon: 'pi pi-file-edit', to: '/therapist/documents/templates', feature: 'documents.templates', proBadge: true },
// "Templates" movido pra /configuracoes/documentos/templates — agora vive em Configurações
{ label: 'Meu link de cadastro', icon: 'pi pi-link', to: '/therapist/patients/link-externo' },
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: '/therapist/patients/cadastro/recebidos', badgeKey: 'cadastrosRecebidos' }
]
+2 -6
View File
@@ -161,12 +161,8 @@ export default {
// ======================================================
// 📄 DOCUMENTOS
// ======================================================
{
path: 'documents/templates',
name: 'admin-documents-templates',
component: () => import('@/features/documents/DocumentTemplatesPage.vue'),
meta: { feature: 'documents.templates' }
},
// /documents/templates removido — mudou pra /configuracoes/documentos/templates
// (setup/config, não operação clínica).
// ======================================================
// 🔐 SEGURANÇA
+11
View File
@@ -167,6 +167,17 @@ export default {
path: 'creditos-whatsapp',
name: 'ConfiguracoesCreditosWhatsapp',
component: () => import('@/layout/configuracoes/ConfiguracoesCreditosWhatsappPage.vue')
},
// ── Documentos & Templates ────────────────────────────────────
// Templates de documento (recibo, atestado, laudo, TCLE, LGPD etc).
// Antes vivia em /therapist/documents/templates — movido pra
// Configurações por ser setup, não operação clínica.
{
path: 'documentos/templates',
name: 'ConfiguracoesDocumentosTemplates',
component: () => import('@/features/documents/DocumentTemplatesPage.vue'),
meta: { feature: 'documents.templates' }
}
]
};
+2 -6
View File
@@ -147,12 +147,8 @@ export default {
component: () => import('@/features/documents/DocumentsListPage.vue'),
meta: { feature: 'documents.upload' }
},
{
path: 'documents/templates',
name: 'therapist-documents-templates',
component: () => import('@/features/documents/DocumentTemplatesPage.vue'),
meta: { feature: 'documents.templates' }
},
// /documents/templates removido — mudou pra /configuracoes/documentos/templates
// (setup/config, não operação clínica).
{
path: 'patients/:id/documents',
name: 'therapist-patient-documents',
+30 -7
View File
@@ -148,7 +148,7 @@ export async function loadTherapistData() {
const { data: profile } = await supabase
.from('profiles')
.select('full_name, phone, professional_registration_type, professional_registration_number, professional_registration_uf')
.select('full_name, phone, professional_registration_type, professional_registration_type_other, professional_registration_number, professional_registration_uf')
.eq('id', ownerId)
.single();
@@ -156,7 +156,10 @@ export async function loadTherapistData() {
const { data: userData } = await supabase.auth.getUser();
const email = userData?.user?.email || '';
const tipo = profile?.professional_registration_type || '';
const tipoRaw = profile?.professional_registration_type || '';
const tipoOther = profile?.professional_registration_type_other || '';
// Quando type='outro', usa o nome livre do conselho/instituição
const tipo = tipoRaw === 'outro' ? tipoOther : tipoRaw;
const numero = profile?.professional_registration_number || '';
const uf = profile?.professional_registration_uf || '';
const registro = formatRegistroProfissional({ tipo, numero, uf });
@@ -173,7 +176,7 @@ export async function loadTherapistData() {
// o número/UF (sem prefixo) pra não duplicar com o "CRP" já no HTML.
// Quando o registro não é CRP, retorna vazio (template visualmente errado
// pede pra usar {{terapeuta_registro}}).
terapeuta_crp: tipo === 'CRP' ? (uf ? `${numero}/${uf}` : numero) : ''
terapeuta_crp: tipoRaw === 'CRP' ? (uf ? `${numero}/${uf}` : numero) : ''
};
}
@@ -240,23 +243,43 @@ export async function loadAllVariables(patientId, agendaEventoId = null, extras
const ownerId = await getOwnerId();
const tenantId = await getActiveTenantId(ownerId);
const [patient, session, therapist, clinic] = await Promise.all([
// Promise.allSettled pra nao mascarar falha individual: se uma source
// falhar, as outras ainda preenchem e a gente loga qual quebrou.
const results = await Promise.allSettled([
loadPatientData(patientId),
loadSessionData(agendaEventoId),
loadTherapistData(),
loadClinicData(tenantId)
]);
const labels = ['patient', 'session', 'therapist', 'clinic'];
const errors = [];
results.forEach((r, i) => {
if (r.status === 'rejected') {
errors.push({ source: labels[i], err: r.reason });
console.error(`[loadAllVariables] falha em ${labels[i]}:`, r.reason);
}
});
const [patient, session, therapist, clinic] = results.map(r => r.status === 'fulfilled' ? r.value : {});
if (import.meta?.env?.DEV) {
console.log('[loadAllVariables] resultados:', {
patient, session, therapist, clinic,
ownerId, tenantId, patientId, agendaEventoId,
errors
});
}
// Resolve valor numérico (extras tem prioridade sobre session)
// Resolve valor numérico (extras tem prioridade sobre session).
// Number.isFinite (strict) em vez de isFinite global: este último coerce
// null pra 0 e retorna true, fazendo null.toFixed crashar logo abaixo.
const valorNum = extras.valor != null
? Number(extras.valor)
: (session.valor ? Number(String(session.valor).replace(/[R$\s.]/g, '').replace(',', '.')) : null);
const valorFormatted = isFinite(valorNum)
const valorFormatted = Number.isFinite(valorNum)
? `R$ ${valorNum.toFixed(2).replace('.', ',')}`
: (session.valor || '');
const valorExtensoStr = isFinite(valorNum) ? valorExtenso(valorNum) : '';
const valorExtensoStr = Number.isFinite(valorNum) ? valorExtenso(valorNum) : '';
const merged = {
...patient,
+43 -36
View File
@@ -44,49 +44,56 @@ async function getActiveTenantId(uid) {
/**
* Variaveis que podem ser usadas nos templates.
* Cada variavel tem: key, label (pt-BR), grupo.
* - key: chave usada em {{variavel}} no HTML do template
* - label: rótulo amigável (pt-BR)
* - grupo: agrupamento visual no editor
* - source: descrição de ONDE o dado é cadastrado (pra exibir como
* hint no dialog "Gerar documento" quando o campo vier vazio).
* É texto explicativo o map real de carregamento vive em
* DocumentGenerate.service.js (loadPatientData / loadTherapistData /
* loadClinicData / loadSessionData).
*/
export const TEMPLATE_VARIABLES = [
// Paciente
{ key: 'paciente_nome', label: 'Nome do paciente', grupo: 'Paciente' },
{ key: 'paciente_nome_social', label: 'Nome social', grupo: 'Paciente' },
{ key: 'paciente_cpf', label: 'CPF do paciente', grupo: 'Paciente' },
{ key: 'paciente_data_nascimento', label: 'Data de nascimento', grupo: 'Paciente' },
{ key: 'paciente_telefone', label: 'Telefone do paciente', grupo: 'Paciente' },
{ key: 'paciente_email', label: 'E-mail do paciente', grupo: 'Paciente' },
{ key: 'paciente_endereco', label: 'Endereço do paciente', grupo: 'Paciente' },
// Paciente — fonte: tabela patients
{ key: 'paciente_nome', label: 'Nome do paciente', grupo: 'Paciente', source: 'Paciente → nome completo' },
{ key: 'paciente_nome_social', label: 'Nome social', grupo: 'Paciente', source: 'Paciente → nome social' },
{ key: 'paciente_cpf', label: 'CPF do paciente', grupo: 'Paciente', source: 'Paciente → CPF' },
{ key: 'paciente_data_nascimento', label: 'Data de nascimento', grupo: 'Paciente', source: 'Paciente → data de nascimento' },
{ key: 'paciente_telefone', label: 'Telefone do paciente', grupo: 'Paciente', source: 'Paciente → telefone' },
{ key: 'paciente_email', label: 'E-mail do paciente', grupo: 'Paciente', source: 'Paciente → e-mail principal' },
{ key: 'paciente_endereco', label: 'Endereço do paciente', grupo: 'Paciente', source: 'Paciente → endereço/número/bairro/cidade/UF' },
// Sessao
{ key: 'data_sessao', label: 'Data da sessão', grupo: 'Sessão' },
{ key: 'hora_inicio', label: 'Hora início', grupo: 'Sessão' },
{ key: 'hora_fim', label: 'Hora fim', grupo: 'Sessão' },
{ key: 'modalidade', label: 'Modalidade (presencial/online)', grupo: 'Sessão' },
// Sessao — fonte: agenda_eventos (só preenche se houver sessao vinculada)
{ key: 'data_sessao', label: 'Data da sessão', grupo: 'Sessão', source: 'Agenda → sessão selecionada (data de início)' },
{ key: 'hora_inicio', label: 'Hora início', grupo: 'Sessão', source: 'Agenda → sessão selecionada (hora de início)' },
{ key: 'hora_fim', label: 'Hora fim', grupo: 'Sessão', source: 'Agenda → sessão selecionada (hora de fim)' },
{ key: 'modalidade', label: 'Modalidade (presencial/online)', grupo: 'Sessão', source: 'Agenda → sessão selecionada (modalidade)' },
// Terapeuta
{ key: 'terapeuta_nome', label: 'Nome do terapeuta', grupo: 'Terapeuta' },
{ key: 'terapeuta_registro', label: 'Registro profissional (ex: CRP 12345/SP)', grupo: 'Terapeuta' },
{ key: 'terapeuta_registro_tipo', label: 'Tipo do registro (CRP/CRM/CRFa…)', grupo: 'Terapeuta' },
{ key: 'terapeuta_registro_numero', label: 'Número do registro', grupo: 'Terapeuta' },
{ key: 'terapeuta_registro_uf', label: 'UF do registro', grupo: 'Terapeuta' },
{ key: 'terapeuta_crp', label: 'CRP (legacy — prefira terapeuta_registro)', grupo: 'Terapeuta' },
{ key: 'terapeuta_email', label: 'E-mail do terapeuta', grupo: 'Terapeuta' },
{ key: 'terapeuta_telefone', label: 'Telefone do terapeuta', grupo: 'Terapeuta' },
// Terapeuta — fonte: profiles (usuário logado) + auth.users
{ key: 'terapeuta_nome', label: 'Nome do terapeuta', grupo: 'Terapeuta', source: 'Perfil → nome completo' },
{ key: 'terapeuta_registro', label: 'Registro profissional (ex: CRP 12345/SP)', grupo: 'Terapeuta', source: 'Perfil → Registro Profissional (tipo + número/UF)' },
{ key: 'terapeuta_registro_tipo', label: 'Tipo do registro (CRP/CRM/CRFa…)', grupo: 'Terapeuta', source: 'Perfil → Registro Profissional → tipo' },
{ key: 'terapeuta_registro_numero', label: 'Número do registro', grupo: 'Terapeuta', source: 'Perfil → Registro Profissional → número' },
{ key: 'terapeuta_registro_uf', label: 'UF do registro', grupo: 'Terapeuta', source: 'Perfil → Registro Profissional → UF' },
{ key: 'terapeuta_crp', label: 'CRP (legacy — prefira terapeuta_registro)', grupo: 'Terapeuta', source: 'Perfil → só preenche se o tipo for "CRP"' },
{ key: 'terapeuta_email', label: 'E-mail do terapeuta', grupo: 'Terapeuta', source: 'Conta → e-mail de login' },
{ key: 'terapeuta_telefone', label: 'Telefone do terapeuta', grupo: 'Terapeuta', source: 'Perfil → telefone' },
// Clinica
{ key: 'clinica_nome', label: 'Nome da clínica', grupo: 'Clínica' },
{ key: 'clinica_endereco', label: 'Endereço da clínica', grupo: 'Clínica' },
{ key: 'clinica_telefone', label: 'Telefone da clínica', grupo: 'Clínica' },
{ key: 'clinica_cnpj', label: 'CNPJ da clínica', grupo: 'Clínica' },
// Clinica — fonte: tabela tenants (clinica ativa do usuário)
{ key: 'clinica_nome', label: 'Nome da clínica', grupo: 'Clínica', source: 'Configurações → Clínica → nome' },
{ key: 'clinica_endereco', label: 'Endereço da clínica', grupo: 'Clínica', source: 'Configurações → Clínica → logradouro/número/bairro/cidade/UF' },
{ key: 'clinica_telefone', label: 'Telefone da clínica', grupo: 'Clínica', source: 'Configurações → Clínica → telefone' },
{ key: 'clinica_cnpj', label: 'CNPJ da clínica', grupo: 'Clínica', source: 'Configurações → Clínica → CPF/CNPJ (preenche só se tiver 14 dígitos)' },
// Financeiro
{ key: 'valor', label: 'Valor (R$)', grupo: 'Financeiro' },
{ key: 'valor_extenso', label: 'Valor por extenso', grupo: 'Financeiro' },
{ key: 'forma_pagamento', label: 'Forma de pagamento', grupo: 'Financeiro' },
// Financeiro — fonte: sessão OU extras (passados pelo chamador)
{ key: 'valor', label: 'Valor (R$)', grupo: 'Financeiro', source: 'Sessão (preço) ou informe manualmente' },
{ key: 'valor_extenso', label: 'Valor por extenso', grupo: 'Financeiro', source: 'Calculado a partir do valor' },
{ key: 'forma_pagamento', label: 'Forma de pagamento', grupo: 'Financeiro', source: 'Informe manualmente (PIX, dinheiro, cartão, etc)' },
// Datas
{ key: 'data_atual', label: 'Data atual', grupo: 'Datas' },
{ key: 'data_atual_extenso', label: 'Data atual por extenso', grupo: 'Datas' },
{ key: 'cidade_estado', label: 'Cidade/UF', grupo: 'Datas' }
// Datas — fonte: clock do sistema / endereço da clínica
{ key: 'data_atual', label: 'Data atual', grupo: 'Datas', source: 'Hoje (preenchido automaticamente)' },
{ key: 'data_atual_extenso', label: 'Data atual por extenso', grupo: 'Datas', source: 'Hoje (preenchido automaticamente)' },
{ key: 'cidade_estado', label: 'Cidade/UF', grupo: 'Datas', source: 'Configurações → Clínica → cidade/UF (últimos 2 elementos do endereço)' }
];
// ── List ────────────────────────────────────────────────────
+141 -3
View File
@@ -103,6 +103,12 @@ const form = reactive({
bio: '',
phone: '',
// Registro profissional (CFP #5 exigido pra emissão de recibos/laudos)
professional_registration_type: '',
professional_registration_type_other: '',
professional_registration_number: '',
professional_registration_uf: '',
site_url: '',
social_instagram: '',
social_youtube: '',
@@ -117,6 +123,24 @@ const form = reactive({
notify_news: false
});
// Opções do CHECK constraint da migration 20260521000003
const REGISTRATION_TYPE_OPTIONS = [
{ value: '', label: '— Não informado —' },
{ value: 'CRP', label: 'CRP — Psicólogo(a)' },
{ value: 'CRM', label: 'CRM — Médico(a)' },
{ value: 'CRFa', label: 'CRFa — Fonoaudiólogo(a)' },
{ value: 'CREFITO', label: 'CREFITO — Fisioterapeuta / T.O.' },
{ value: 'CRESS', label: 'CRESS — Assistente Social' },
{ value: 'CRN', label: 'CRN — Nutricionista' },
{ value: 'RMS', label: 'RMS — Residência Multiprofissional' },
{ value: 'outro', label: 'Outro' }
];
const UF_OPTIONS = [
'AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB',
'PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO'
].map(uf => ({ value: uf, label: uf }));
const customSocials = ref([]);
function addCustomSocial() {
@@ -611,7 +635,7 @@ async function loadProfile() {
const { data: prof, error: pErr } = await supabase
.from('profiles')
.select(
'role, full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom, language, timezone, notify_system_email, notify_reminders, notify_news'
'role, full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom, language, timezone, notify_system_email, notify_reminders, notify_news, professional_registration_type, professional_registration_type_other, professional_registration_number, professional_registration_uf'
)
.eq('id', user.id)
.maybeSingle();
@@ -631,6 +655,11 @@ async function loadProfile() {
form.social_facebook = prof.social_facebook ?? '';
form.social_x = prof.social_x ?? '';
form.professional_registration_type = prof.professional_registration_type ?? '';
form.professional_registration_type_other = prof.professional_registration_type_other ?? '';
form.professional_registration_number = prof.professional_registration_number ?? '';
form.professional_registration_uf = prof.professional_registration_uf ?? '';
if (Array.isArray(prof.social_custom)) {
customSocials.value = prof.social_custom;
}
@@ -707,7 +736,17 @@ async function saveAll() {
notify_system_email: !!form.notify_system_email,
notify_reminders: !!form.notify_reminders,
notify_news: !!form.notify_news
notify_news: !!form.notify_news,
// Registro profissional (CFP) null se vazio
professional_registration_type: String(form.professional_registration_type || '').trim() || null,
// type_other só preenchido quando type === 'outro' (limpa quando muda)
professional_registration_type_other:
form.professional_registration_type === 'outro'
? (String(form.professional_registration_type_other || '').trim() || null)
: null,
professional_registration_number: String(form.professional_registration_number || '').trim() || null,
professional_registration_uf: String(form.professional_registration_uf || '').trim() || null
};
const { data: updatedProfile, error: pErr2 } = await supabase
@@ -715,7 +754,7 @@ async function saveAll() {
.update(profilePayload)
.eq('id', userId.value)
.select(
'id, role, full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom, language, timezone, notify_system_email, notify_reminders, notify_news, updated_at'
'id, role, full_name, avatar_url, phone, bio, nickname, work_description, work_description_other, site_url, social_instagram, social_youtube, social_facebook, social_x, social_custom, language, timezone, notify_system_email, notify_reminders, notify_news, professional_registration_type, professional_registration_type_other, professional_registration_number, professional_registration_uf, updated_at'
)
.single();
@@ -1105,6 +1144,105 @@ onBeforeUnmount(() => {
</div>
</div>
<!-- REGISTRO PROFISSIONAL (CFP #5) -->
<div
id="registro-profissional"
class="pcard relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-6 scroll-mt-20 transition-[border-color] duration-[220ms]"
style="--c: #0ea5e9; --c-dim: rgba(14, 165, 233, 0.08); --c-border: rgba(14, 165, 233, 0.2)"
>
<div class="pcard__shine" />
<div class="flex items-center gap-2.5 mb-3.5">
<div class="w-9 h-9 rounded-[0.625rem] shrink-0 flex items-center justify-center" style="background: var(--c-dim); border: 1px solid var(--c-border); color: var(--c)"><i class="pi pi-id-card" /></div>
<span class="text-[1rem] font-extrabold uppercase tracking-[0.14em] opacity-75" style="color: var(--c)">Registro Profissional</span>
</div>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] mb-1">Conselho regional</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">Exigido para emissão de recibos, atestados e laudos. Aparecerá no rodapé dos documentos.</div>
<div class="h-px bg-[var(--surface-border)] my-5" />
<div class="grid grid-cols-12 gap-4">
<!-- Tipo de conselho -->
<div class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<Select
id="prof_registration_type"
v-model="form.professional_registration_type"
:options="REGISTRATION_TYPE_OPTIONS"
optionLabel="label"
optionValue="value"
class="w-full"
@update:modelValue="markDirty"
/>
<label for="prof_registration_type">Tipo de registro</label>
</FloatLabel>
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Conselho profissional ao qual você é vinculado.</div>
</div>
<!-- Campo livre quando tipo='outro' -->
<div v-if="form.professional_registration_type === 'outro'" class="col-span-12 md:col-span-6">
<FloatLabel variant="on">
<InputText
id="prof_registration_type_other"
v-model="form.professional_registration_type_other"
class="w-full"
@input="markDirty"
/>
<label for="prof_registration_type_other">Nome do conselho/instituição <span class="text-red-400">*</span></label>
</FloatLabel>
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Ex: APM (Associação Paulista de Medicina), ABRAP, etc.</div>
</div>
<!-- Número -->
<div class="col-span-7 md:col-span-3">
<FloatLabel variant="on">
<InputText
id="prof_registration_number"
v-model="form.professional_registration_number"
class="w-full"
:disabled="!form.professional_registration_type"
@input="markDirty"
/>
<label for="prof_registration_number">Número</label>
</FloatLabel>
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Ex: 06/12345</div>
</div>
<!-- UF -->
<div class="col-span-5 md:col-span-3">
<FloatLabel variant="on">
<Select
id="prof_registration_uf"
v-model="form.professional_registration_uf"
:options="UF_OPTIONS"
optionLabel="label"
optionValue="value"
:disabled="!form.professional_registration_type"
class="w-full"
:filter="true"
@update:modelValue="markDirty"
/>
<label for="prof_registration_uf">UF</label>
</FloatLabel>
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">Estado do conselho.</div>
</div>
<!-- Preview -->
<div v-if="form.professional_registration_type && form.professional_registration_number" class="col-span-12">
<div class="rounded-md border border-[var(--c-border)] bg-[var(--c-dim)] p-3 text-[0.9rem]">
<span class="text-[var(--text-color-secondary)] mr-2">Aparecerá nos documentos como:</span>
<strong class="text-[var(--text-color)]">
{{ form.professional_registration_type === 'outro'
? (form.professional_registration_type_other || 'Conselho não informado')
: form.professional_registration_type }}
{{ form.professional_registration_number }}{{ form.professional_registration_uf ? '/' + form.professional_registration_uf : '' }}
</strong>
</div>
</div>
</div>
</div>
<!-- 02 SITES E REDES SOCIAIS -->
<div
id="redes-sociais"