Compare commits
52 Commits
661790d577
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b0b636c660 | |||
| 701d9f4fcc | |||
| 34412c6883 | |||
| 3a42b0696d | |||
| 7dd8cde8b4 | |||
| ec56f9429b | |||
| 89bf181742 | |||
| 342defecde | |||
| fff70e4a71 | |||
| 550c4ade44 | |||
| 473e0f026e | |||
| 9f3a047d6d | |||
| 8bf992910d | |||
| fa2b431a56 | |||
| eb42759979 | |||
| c17c547ed2 | |||
| 4f05c2cf1b | |||
| 512bcc979c | |||
| 61bb0d9c26 | |||
| 6c39c58dc8 | |||
| 4e1ebeba13 | |||
| 51c33e73b9 | |||
| 682840f355 | |||
| c6105df98a | |||
| 402def7539 | |||
| 5dc91614ad | |||
| 597f8c05d5 | |||
| 79425a3c9a | |||
| 87a1ac1358 | |||
| 6860628087 | |||
| 134f562a1f | |||
| bbbb08ba9d | |||
| 17f114f32f | |||
| c9afe8f009 | |||
| c7e311b851 | |||
| 0aabea7753 | |||
| 80cce772db | |||
| f1c24242e0 | |||
| b821db6438 | |||
| 0fafc28581 | |||
| 75e67eae5d | |||
| 9a6eb56827 | |||
| 652571da69 | |||
| 30367392ff | |||
| b40116fe5d | |||
| ffd8eab72d | |||
| dee89ccd84 | |||
| 6a8ee52ad8 | |||
| 7516468f78 | |||
| 20d2b3aee4 | |||
| ae1e1388b9 | |||
| 4024469952 |
@@ -14,6 +14,86 @@ Chronological, append-only record of everything that's happened in this wiki.
|
||||
|
||||
---
|
||||
|
||||
## [2026-05-22 dia] session | Melissa UX overhaul + 5 docs saas (Fases 2-5)
|
||||
Touched: none codigo durable; 5 docs saas novas em development/saas-docs/
|
||||
|
||||
Sessao longa (~12 commits codigo + 5 docs). 2 grandes blocos:
|
||||
|
||||
BLOCO 1 — Melissa UI overhaul (manha):
|
||||
- Tray no canto inf. direito (substitui topbar band do topo): busca +
|
||||
plan-DEV + bell + ajuda + cog. Sibling de .melissa-dock (fora de
|
||||
.win11-summary) pra ficar interativa com secao aberta. Em <md (768px)
|
||||
collapse parcial — bell/help/cog/plan-DEV viram popup vertical no
|
||||
botao ⋮; dot vermelho no ⋮ quando ha notificacoes nao lidas.
|
||||
- Busca global unificada: MelissaBusca ganha parser de data (hoje/
|
||||
amanha/ontem/DD/MM/YYYY) + card azul "Ir para [data]" + emit
|
||||
goto-date. Popover da agenda (MelissaAgendaSearchPopover) deletado;
|
||||
Ctrl+K so vive na MelissaBusca. Lupa unica fica so na .melissa-tray
|
||||
(removida das toolbars de secoes pra evitar pollution mobile).
|
||||
- Dock: 4 builtins (Agenda · Pacientes · WhatsApp · Financeiro). MRU
|
||||
oculto em <md via @media (utility 'hidden' do tailwind perdia pro
|
||||
.dock-pin{display:grid} por carga).
|
||||
- Hero resumo: contagem "(x foi cancelado, x foi remarcado)" depois
|
||||
do chip atendimentos com gramatica plural.
|
||||
- Settings + Ajuda fecham ao clicar fora (mousedown capture + watch
|
||||
open). Cog ref + data-ajuda-toggle ignoram trigger pra evitar
|
||||
close+reopen.
|
||||
- Cronometro: pre-selecao paciente + autostart quando aberto via
|
||||
botao ⏱ na timeline (sessao em curso) ou card "Proximo paciente".
|
||||
abrir(opts) com { pacienteId, autostart, sessionPlan }. sessionPlan
|
||||
exibe "Programado: HH:MM – HH:MM" + badge "atrasada X min"; NAO
|
||||
desconta atraso auto. Confirm fechar quando ha sessao rodando/
|
||||
decorrido sem salvar. Chip minimizado oculta nome do paciente em
|
||||
<md (so icone + tempo).
|
||||
- Documents: linkage document_generated.documento_id agora preenchido
|
||||
no INSERT (era sempre NULL). Modo edit in-place via editingDocId:
|
||||
busca template+dados_preenchidos via loadGeneratedFromDocId, popula
|
||||
vars, pula pra step 'edit'; save substitui PDF no Storage e
|
||||
atualiza documents (preserva id+audit). Header amber "Editar
|
||||
documento" + botao "Substituir documento". Backfill SQL pra docs
|
||||
antigos (3 linkados, 5 orfaos no DB local).
|
||||
- DocumentPreviewDialog: wire-up dos 5 botoes da sidebar (download/
|
||||
editar/share/sign/delete) que estavam caindo no vazio.
|
||||
|
||||
BLOCO 2 — saas-docs (tarde):
|
||||
Padrao igual da 01-busca-global-melissa.json — JSON-fonte +
|
||||
SQL de import direto via $HTML$/$FAQ$ dollar quoting. 5 docs novas
|
||||
(03 a 07), cada uma com 12 FAQ itens:
|
||||
|
||||
- 03 Documentos do paciente — pagina_path /melissa/paciente,
|
||||
categoria Documentos
|
||||
- 04 Templates de documentos — pagina_path /melissa/documentos-
|
||||
templates, categoria Documentos
|
||||
- 05 Assinatura eletronica — pagina_path /melissa/paciente,
|
||||
categoria Documentos
|
||||
- 06 Recibo profissional — pagina_path /melissa/agenda, categoria
|
||||
Financeiro (cobre fluxo do AgendaEventoFinanceiroPanel)
|
||||
- 07 Relatorios e exportacao — pagina_path /melissa/relatorios,
|
||||
categoria Relatorios
|
||||
|
||||
Todas importadas no DB local via docker exec psql. Total acumulado:
|
||||
7 docs ativas em saas_docs (busca + cronometro + os 5 novos).
|
||||
|
||||
PROXIMA SESSAO (retomar 23/05):
|
||||
- Fase 6 RESTANTE: C12 UX iter (cronometro/sessao antecipar pgto —
|
||||
flow DB ja ok, UX obscura adiada em 20/05). Unico item de codigo
|
||||
da lista de ontem.
|
||||
- Fase 7 RESTANTE: Regressao Agenda C7-C13 (validacao manual; eu
|
||||
nao executo, so listo plano de teste se quiser).
|
||||
- Antes/depois: olhada no ROADMAP.md canonico pra panorama MVP
|
||||
real. Itens visiveis ainda no horizonte: #12 papel timbrado
|
||||
(bloqueado, codigo no UniaoApp), #15 NFS-e (esforco L), §1.5
|
||||
Sentry+qualidade, Asaas Fase B (bloqueado), M4 cutover billing
|
||||
(depende decisoes #2/#3/#6), validacao centralizada CPF/CNPJ/tel.
|
||||
|
||||
ITENS TESTADOS HOJE (✅): tray + busca unificada + cronometro
|
||||
evento-aware + edicao in-place de docs gerados + Fase 2.7-2.9
|
||||
(gerar PDF, vars CRP/UF, tipo_documento='outro').
|
||||
|
||||
PUSH: 12 commits pushados (c17c547..701d9f4) usando workaround SSL
|
||||
(git -c http.sslVerify=false push). Credenciais pediram 1x, depois
|
||||
cacheou pra sessao toda.
|
||||
|
||||
## [2026-05-20 18:30] session | C12 deferred + C13 prep (lock ja existia em Fase 6)
|
||||
Touched: none (codigo + HANDOFF; memoria project_c12_antecipar_iterar)
|
||||
Detalhes:
|
||||
@@ -1430,6 +1510,37 @@ closure.
|
||||
Pendente: Fase C (adapter Rail) + Fase D (adapter Clinica) +
|
||||
doc ajuda.
|
||||
|
||||
## [2026-05-21 dawn] session | migrations + seeds aplicados no banco local
|
||||
Touched: none
|
||||
|
||||
Aplicou todas as 13 migrations pendentes do dia (clinical_notes
|
||||
tables/rls/versioning + documents link + accept_invite RPC + asaas
|
||||
tables/rls + profiles registration + specialties + document_templates
|
||||
consent types + sign_document RPCs + list_my_signatures + recibo
|
||||
amend) + 3 seeds novos (seed_040 clinical_note_templates 6 entries +
|
||||
seed_050 specialties 34 entries + seed_060 consent forms 2 templates
|
||||
LGPD/Gravacao + amend tcle_online).
|
||||
|
||||
Gotcha re-validado (memoria atualizada): migration 20260521000005
|
||||
estendendo CHECK dt_tipo_check foi marcada aplicada pelo db.cjs mas
|
||||
silenciosamente ROLLBACK (postgres nao e owner de document_templates).
|
||||
Detectado quando seed_060 falhou com violates check constraint.
|
||||
Re-rodada via `docker exec -i ... sh -c 'psql -U supabase_admin -h
|
||||
127.0.0.1 -d postgres'` (trust pra 127.0.0.1/32 em pg_hba.conf).
|
||||
|
||||
db.config.json estendido com os 3 seeds novos (system group, ordem
|
||||
seed_040 -> seed_050 -> seed_060) pra setup do zero rodar tudo.
|
||||
|
||||
Sanity check pos-aplicacao:
|
||||
- 5 RPCs novas (accept_tenant_invite + 3 sign + list_my_signatures)
|
||||
- 8 tabelas novas (clinical_notes + versions + templates + asaas
|
||||
customers/payments/webhook + profile_specialties + specialties)
|
||||
- 17 document_templates global (15 existentes + 2 LGPD/Gravacao)
|
||||
- 34 specialties seedadas
|
||||
- 6 clinical_note_templates seedados
|
||||
- 3 colunas professional_registration_* em profiles
|
||||
- Backup automatico criado em backups/2026-05-21/
|
||||
|
||||
## [2026-05-21 deep night] session | agenda Fases C + D — Rail+Clinica adotam billing core
|
||||
Touched: none
|
||||
|
||||
@@ -1477,3 +1588,57 @@ TOTAL DA SESSAO (24/05 - 25/05, ~24 commits):
|
||||
- Agenda decomposicao A+B1+B2: -991L em useMelissaAgenda (~33%)
|
||||
- Agenda Fases C+D: Rail+Clinica adotam billing core
|
||||
- useAgendaStatusChange composable novo
|
||||
|
||||
## [2026-05-21 23:00] session | Melissa Fase 2 UX iter + bug isFinite(null)
|
||||
Touched: feedback_isfinite_strict, feedback_teleport_body_styles
|
||||
Detalhes:
|
||||
|
||||
Sessao de testes manuais Fase 2 (templates + paciente.documentos).
|
||||
4 ajustes UX + 1 bug funcional resolvido. 5 commits, 0 push (SSL
|
||||
self-signed Gitea — user faz manual amanha).
|
||||
|
||||
1) MelissaPatientDocuments (4e1ebeb, 6c39c58):
|
||||
Aba Documentos no /melissa/paciente?id=X foi convertida de embed
|
||||
<DocumentsListPage> pra pagina nativa 2-col Melissa. Drawer mobile
|
||||
bugava (transform/filter em ancestrais trapando position:fixed).
|
||||
Fix:
|
||||
- <Teleport to="body"> no drawer + backdrop pra escapar stacking
|
||||
- styles do drawer movidos pra <style> nao-scoped (teleport perde
|
||||
data-v attrs do scoped)
|
||||
- wrapper teleportado recebe class "win11-root" pra herdar vars
|
||||
--m-* (definidas nesse escopo no MelissaLayout)
|
||||
- cascata --mpd-bg/border/text: --m-* -> --p-* -> hardcoded
|
||||
|
||||
2) DocumentGenerateDialog (61bb0d9, 512bcc9):
|
||||
Inputs trocados pra FloatLabel variant="on". Adicionado map de
|
||||
ORIGEM dos campos (TEMPLATE_VARIABLES.source) — hint embaixo de
|
||||
cada campo vazio explica onde cadastrar (ex: "Perfil -> Registro
|
||||
Profissional"). Banner verde/amber no topo conta preenchidos.
|
||||
|
||||
3) Bug critico (4f05c2c) — RAIZ do "campos vem vazio mesmo com
|
||||
profile preenchido":
|
||||
loadAllVariables crashava com TypeError "Cannot read properties
|
||||
of null (reading toFixed)" quando NAO havia sessao vinculada
|
||||
(agendaEventoId=null) E sem extras.valor. Toda a Promise
|
||||
estourava, variables zerava.
|
||||
|
||||
Causa: isFinite(null) global retorna TRUE (Number(null)===0),
|
||||
entrava no branch valorNum.toFixed e crashava.
|
||||
|
||||
Fix: trocar por Number.isFinite (strict, nao coerce).
|
||||
Salvo como memoria feedback_isfinite_strict.
|
||||
|
||||
PROXIMA SESSAO (retomar amanha 22/05):
|
||||
- Continuar Fase 2: 2.7-2.9 (gerar PDF dentro da aba Documentos
|
||||
do paciente, conferir vars CRP/UF preenchem, doc aparece como
|
||||
tipo_documento='outro')
|
||||
- Gerar JSON docs Fase 2 (#6 + templates page)
|
||||
- Fase 3: Portal assinatura #7
|
||||
- Fase 4: Recibo profissional #14 testes
|
||||
- Fase 5: Relatorios export #13
|
||||
- Fase 6: C12 UX iter (deferred 20/05)
|
||||
- Fase 7: Regressao Agenda C7-C13
|
||||
|
||||
PUSH PENDENTE: 35 commits ahead of origin/main; SSL self-signed
|
||||
do Gitea exige `git -c http.sslVerify=false push origin main`
|
||||
+ credenciais (user faz manual).
|
||||
|
||||
@@ -22,7 +22,10 @@
|
||||
"seed_015_document_templates.sql",
|
||||
"seed_030_dev_phases_items.sql",
|
||||
"seed_031_dev_auditoria.sql",
|
||||
"seed_032_dev_competitors.sql"
|
||||
"seed_032_dev_competitors.sql",
|
||||
"seed_040_clinical_note_templates.sql",
|
||||
"seed_050_specialties.sql",
|
||||
"seed_060_consent_forms_extra.sql"
|
||||
],
|
||||
"test_data": [
|
||||
"seed_020_test_data.sql"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- ============================================================================
|
||||
-- Compliance CFP #5 — campo livre quando tipo de registro = 'outro'
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Migration 20260521000003 adicionou professional_registration_type com CHECK
|
||||
-- limitado a 8 valores (CRP/CRM/CRFa/CREFITO/CRESS/CRN/RMS/outro). Quando o
|
||||
-- profissional escolhe 'outro', precisa informar qual conselho/instituição
|
||||
-- (ex: associações privadas, conselhos não-listados).
|
||||
--
|
||||
-- Esta migration adiciona professional_registration_type_other (text livre),
|
||||
-- que só é preenchido quando type = 'outro'.
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE public.profiles
|
||||
ADD COLUMN IF NOT EXISTS professional_registration_type_other text;
|
||||
|
||||
COMMENT ON COLUMN public.profiles.professional_registration_type_other IS
|
||||
'Nome livre do conselho/instituição quando professional_registration_type = ''outro''. Aparece em recibos/laudos no lugar do tipo padrão.';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Backfill: linkar document_generated.documento_id em registros antigos
|
||||
-- pra suportar re-edicao in-place de documentos gerados.
|
||||
--
|
||||
-- O codigo novo (DocumentGenerate.service.js saveGeneratedDocument) ja
|
||||
-- preenche o documento_id no INSERT pra criacoes novas. Este script eh
|
||||
-- one-off pra docs gerados ANTES desse fix.
|
||||
--
|
||||
-- Match: dg.pdf_path = d.bucket_path + match de tenant/patient pra evitar
|
||||
-- linkar a doc errado em caso colidente. Registros sem match (paths que
|
||||
-- nao existem mais em documents — docs deletados/cleanup) ficam orfaos
|
||||
-- com documento_id=NULL: nao quebra nada, so nao tem caminho de re-edit.
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE public.document_generated dg
|
||||
SET documento_id = d.id
|
||||
FROM public.documents d
|
||||
WHERE dg.documento_id IS NULL
|
||||
AND dg.pdf_path = d.bucket_path
|
||||
AND dg.patient_id = d.patient_id
|
||||
AND dg.tenant_id = d.tenant_id
|
||||
AND d.deleted_at IS NULL;
|
||||
|
||||
-- Relatorio pos-backfill
|
||||
DO $REPORT$
|
||||
DECLARE
|
||||
v_linked int;
|
||||
v_orphans int;
|
||||
BEGIN
|
||||
SELECT count(*) FILTER (WHERE documento_id IS NOT NULL),
|
||||
count(*) FILTER (WHERE documento_id IS NULL)
|
||||
INTO v_linked, v_orphans
|
||||
FROM public.document_generated;
|
||||
RAISE NOTICE 'document_generated: % linked, % orphans (sem documents correspondente)',
|
||||
v_linked, v_orphans;
|
||||
END;
|
||||
$REPORT$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,166 @@
|
||||
-- Importacao da doc Assinatura eletronica de documentos (Fase 3 #7)
|
||||
-- Gerado a partir de development/saas-docs/05-assinatura-eletronica-melissa.json
|
||||
BEGIN;
|
||||
|
||||
DO $IMPORT$
|
||||
DECLARE
|
||||
v_doc_id uuid;
|
||||
BEGIN
|
||||
INSERT INTO public.saas_docs (
|
||||
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
|
||||
pagina_path, ordem, ativo, medias
|
||||
) VALUES (
|
||||
'Assinatura eletrônica de documentos',
|
||||
$HTML$<h2>Assinatura eletrônica de documentos</h2>
|
||||
|
||||
<p>O sistema permite enviar documentos clínicos (TCLE, contratos, autorizações, laudos) pro paciente assinar <strong>sem que ele precise ter login</strong>. O fluxo registra a assinatura com hash do conteúdo, IP, user-agent e timestamp — gerando um audit trail compliance LGPD/CFP.</p>
|
||||
|
||||
<h3>1. Visão geral do fluxo</h3>
|
||||
<ol>
|
||||
<li><strong>Terapeuta</strong> abre o documento no prontuário e clica em <em>Assinar</em></li>
|
||||
<li>Adiciona os signatários (nome + email) e ativa <em>"Gerar link público para assinatura"</em></li>
|
||||
<li>Sistema cria signature requests + um <strong>link público temporário</strong> com token</li>
|
||||
<li>Terapeuta copia a URL e envia pro paciente (WhatsApp, email, SMS — manual por enquanto)</li>
|
||||
<li><strong>Paciente</strong> abre o link em qualquer navegador, lê o documento, marca o checkbox de aceite LGPD e clica <em>Assinar</em></li>
|
||||
<li>Sistema computa SHA-256 do PDF baixado, registra assinatura via RPC server-side (IP/UA capturados pelo banco)</li>
|
||||
<li>Terapeuta vê o status atualizado no documento (pendente → assinado)</li>
|
||||
</ol>
|
||||
|
||||
<h3>2. Lado terapeuta — criar solicitação</h3>
|
||||
<p>No preview de um documento (na aba <em>Documentos</em> do prontuário), clique no botão <strong>Assinar</strong> na sidebar de ações. O <em>DocumentSignatureDialog</em> abre com:</p>
|
||||
<ul>
|
||||
<li><strong>Lista de signatários:</strong> adicione um ou mais — pra cada um, nome + email são obrigatórios. O paciente principal vem pré-preenchido se disponível.</li>
|
||||
<li><strong>Toggle "Gerar link público para assinatura"</strong> (default ON): cria um share_link junto com as signature requests. Sem isso, só fica a request registrada — o paciente precisa logar no portal pra assinar.</li>
|
||||
<li><strong>Select de validade do link:</strong> 24h / 3 dias / 7 dias / 30 dias. Default 7 dias (168h).</li>
|
||||
<li><strong>Submit:</strong> cria as requests + link e mostra a URL pronta pra copiar.</li>
|
||||
</ul>
|
||||
|
||||
<h3>3. Lado paciente — link público (sem login)</h3>
|
||||
<p>Ao abrir o link <code>/shared/document/:token</code>, o paciente vê:</p>
|
||||
<ul>
|
||||
<li><strong>Preview do PDF</strong> inline (iframe)</li>
|
||||
<li><strong>Painel azul</strong> embaixo do preview com:
|
||||
<ul>
|
||||
<li>Aviso LGPD/CFP explicando o que vai ser registrado</li>
|
||||
<li>Checkbox <em>"Li o documento e concordo com seu conteúdo"</em> (bloqueia botões até marcado)</li>
|
||||
<li>Select de signatário (se houver mais de um cadastrado)</li>
|
||||
<li>Botões <strong>Assinar</strong> (emerald) e <strong>Recusar</strong> (rose)</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p>Ao clicar Assinar:</p>
|
||||
<ol>
|
||||
<li>Sistema baixa o PDF e computa <strong>SHA-256</strong> client-side (proof of integrity — se o doc foi alterado depois, hash não bate)</li>
|
||||
<li>Chama a RPC <code>sign_document_by_token</code> passando o hash</li>
|
||||
<li>RPC captura <strong>IP via inet_client_addr()</strong> e <strong>user-agent via current_setting('request.headers')</strong> — server-side, à prova de spoof client-side</li>
|
||||
<li>Registra em <code>document_signatures</code>: timestamp, hash, IP, UA, status='assinado'</li>
|
||||
<li>Mostra tela de confirmação "Documento assinado com sucesso"</li>
|
||||
</ol>
|
||||
|
||||
<h3>4. Audit trail registrado</h3>
|
||||
<p>Cada assinatura grava:</p>
|
||||
<ul>
|
||||
<li><strong>signatario_id</strong> (se o signatário tem cadastro como paciente vinculado)</li>
|
||||
<li><strong>signatario_nome</strong> e <strong>signatario_email</strong> (do que foi cadastrado pelo terapeuta)</li>
|
||||
<li><strong>assinado_em</strong> — timestamp da RPC (server time, não client clock)</li>
|
||||
<li><strong>assinatura_hash</strong> — SHA-256 do PDF no momento da assinatura</li>
|
||||
<li><strong>ip_address</strong> — capturado server-side (anti-spoof)</li>
|
||||
<li><strong>user_agent</strong> — header HTTP via current_setting</li>
|
||||
<li><strong>status</strong> — pendente / enviado / assinado / recusado / expirado</li>
|
||||
</ul>
|
||||
|
||||
<div style="background: rgba(16,185,129,0.08); border: 1px solid rgba(16,185,129,0.3); border-radius: 10px; padding: 12px 14px; margin: 14px 0; font-size: 0.85rem;">
|
||||
<strong>🛡️ Por que server-side?</strong> Capturar IP/UA no banco via <code>inet_client_addr()</code> é anti-spoof: o cliente não consegue forjar valores arbitrários. Garante que o audit trail reflete a sessão HTTP real, não um POST manipulado.
|
||||
</div>
|
||||
|
||||
<h3>5. Recusar a assinatura</h3>
|
||||
<p>O paciente pode <strong>recusar</strong> em vez de assinar — útil se ele não concorda com o conteúdo. Click em <strong>Recusar</strong> abre um <em>confirm</em>; ao confirmar, o sistema registra a recusa (com timestamp + IP/UA da mesma forma) e marca a request como <code>status='recusado'</code>. O terapeuta vê isso na lista de signature requests e pode entrar em contato pra ajustar o documento.</p>
|
||||
|
||||
<h3>6. Portal do paciente — lista de pendências</h3>
|
||||
<p>Pacientes logados no portal (<code>/portal/documentos</code>) veem uma lista de TODOS os documentos solicitados pra eles, com KPIs no topo:</p>
|
||||
<ul>
|
||||
<li><strong>Total</strong> · <strong>Pendentes</strong> · <strong>Assinados</strong> · <strong>Recusados</strong></li>
|
||||
</ul>
|
||||
<p>Filtro por status (todos / pendentes / assinados) + lista. Click em <strong>Assinar agora</strong> num item pendente leva pro <code>/shared/document/:token</code> (mesma página pública, mas com auth já garantida via portal).</p>
|
||||
|
||||
<h3>7. Expiração e múltiplos usos</h3>
|
||||
<ul>
|
||||
<li><strong>Validade do link:</strong> configurada na criação (24h/3d/7d/30d). Após expirar, retorna 410 Gone. Terapeuta pode gerar novo link pelo botão <em>Compartilhar</em> no preview do doc.</li>
|
||||
<li><strong>Limite de usos:</strong> calculado como <code>max(signatários × 3, 5)</code> — gerar 1 signatário dá 5 usos disponíveis (margem de erro / reload / multi-device).</li>
|
||||
<li><strong>Cada assinatura é única:</strong> mesmo signatário não consegue assinar 2x — a RPC bloqueia se já houver registro com status=assinado.</li>
|
||||
</ul>
|
||||
|
||||
<h3>8. Múltiplos signatários</h3>
|
||||
<p>Documentos como termo de autorização de menor podem precisar de 2+ assinaturas (responsável legal + paciente menor, ou os dois pais). O dialog aceita N signatários; cada um recebe sua própria entry em <code>document_signatures</code>. O link público é o mesmo — quando o paciente abre, escolhe qual signatário ele é no select e assina apenas a sua entry.</p>
|
||||
|
||||
<h3>9. Validade legal</h3>
|
||||
<p>A assinatura eletrônica registrada pelo sistema atende:</p>
|
||||
<ul>
|
||||
<li><strong>LGPD (Lei 13.709/2018):</strong> consentimento explícito registrado com timestamp, IP e UA — base legal Art. 7º I</li>
|
||||
<li><strong>Código de Ética CFP:</strong> documento clínico com identificação inequívoca do signatário (nome+email+IP+hash)</li>
|
||||
<li><strong>MP 2200-2/2001 (ICP-Brasil):</strong> assinatura <em>simples</em> com integridade via hash — não é certificado A1/A3, mas é válida pra documentos sem exigência ICP</li>
|
||||
</ul>
|
||||
<p>⚠️ Pra documentos que exigem ICP-Brasil (notarial, procuração com poderes especiais), use uma plataforma externa de assinatura qualificada — esse fluxo não substitui.</p>
|
||||
|
||||
<h3>⚠️ Notas pro desenvolvedor</h3>
|
||||
<ul>
|
||||
<li><strong>RPCs:</strong> <code>sign_document_by_signature_id</code> (paciente logado no portal), <code>sign_document_by_token</code> (link público), <code>get_signable_document_by_token</code> (resolve token → doc + signature_request), <code>list_my_signatures</code> (lista do paciente, cruza por signatario_id, signatario_email e patient.user_id)</li>
|
||||
<li><strong>Service:</strong> <code>DocumentSignatures.service.js</code> com wrappers <code>signByPortal</code>, <code>signByToken</code>, <code>getSignableDocumentByToken</code>, <code>listMySignatures</code>, <code>hashDocument</code>, <code>refuseSignature</code>, <code>createSignatureRequests</code>, <code>createShareLink</code>, <code>buildShareUrl</code></li>
|
||||
<li><strong>Composable:</strong> <code>useDocumentSignatures</code> (Tipo A blueprint)</li>
|
||||
<li><strong>UI lado terapeuta:</strong> <code>DocumentSignatureDialog.vue</code> (component)</li>
|
||||
<li><strong>UI lado paciente:</strong> <code>PortalDocumentos.vue</code> (portal logado) + <code>SharedDocumentPage.vue</code> (link público)</li>
|
||||
<li><strong>Notificação automática</strong> (paciente recebe email/WA quando signature criada) — pendente, depende de Módulo 6 (notifications factory channel)</li>
|
||||
</ul>$HTML$,
|
||||
'Documentos',
|
||||
true,
|
||||
'usuario',
|
||||
'/melissa/paciente',
|
||||
5,
|
||||
true,
|
||||
'[{"tipo": "imagem", "url": ""}]'::jsonb
|
||||
)
|
||||
RETURNING id INTO v_doc_id;
|
||||
|
||||
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
|
||||
(v_doc_id, 'Como peço pra um paciente assinar um documento (TCLE, contrato, autorização)?',
|
||||
$FAQ$Na aba <strong>Documentos</strong> do prontuário, clique no doc → no preview, clique em <strong>Assinar</strong> (sidebar de ações). O dialog abre. Adicione o paciente como signatário (nome + email), mantenha <em>"Gerar link público para assinatura"</em> marcado, escolha validade (7 dias é o default) e clique em <strong>Solicitar</strong>. O sistema cria a request e mostra uma URL pra copiar. Envie pro paciente via WhatsApp, email, SMS — como preferir.$FAQ$, 0, true),
|
||||
|
||||
(v_doc_id, 'Como o paciente assina sem ter login no sistema?',
|
||||
$FAQ$Ele abre o link público (<code>/shared/document/:token</code>) em qualquer navegador. Vê o PDF inline, lê o aviso LGPD/CFP, marca o checkbox <em>"Li o documento e concordo"</em>, e clica <strong>Assinar</strong>. O sistema computa hash SHA-256 do PDF, chama a RPC server-side que captura IP/User-Agent e registra a assinatura. Nada de cadastro, nada de senha.$FAQ$, 1, true),
|
||||
|
||||
(v_doc_id, 'Que informação fica registrada quando ele assina?',
|
||||
$FAQ$Tudo que precisa pra audit compliance: <strong>nome e email</strong> do signatário (do cadastro), <strong>timestamp server-side</strong> (não do relógio do cliente), <strong>hash SHA-256 do PDF</strong> no momento da assinatura (qualquer alteração posterior invalida a integridade), <strong>IP</strong> e <strong>User-Agent</strong> capturados pelo banco via <code>inet_client_addr()</code> e <code>current_setting('request.headers')</code> — anti-spoof. Tudo fica em <code>public.document_signatures</code>.$FAQ$, 2, true),
|
||||
|
||||
(v_doc_id, 'O terapeuta também precisa assinar o documento?',
|
||||
$FAQ$Depende do tipo. Pra atestados, laudos e declarações, geralmente sim — você gera o PDF a partir do template (que já contém seu nome + registro profissional + assinatura digitalizada se você incluiu no rodapé). Pra contratos e termos com paciente como contraparte, você adiciona você mesmo como segundo signatário no dialog antes de enviar. Cada um abre o link e assina sua entry separadamente.$FAQ$, 3, true),
|
||||
|
||||
(v_doc_id, 'O link tem validade? E se expirar?',
|
||||
$FAQ$Tem. Você escolhe 24h, 3 dias, 7 dias ou 30 dias na hora de criar (default 7d). Depois disso o link retorna erro 410 Gone. Se o paciente não assinou a tempo, gere um novo link: no preview do doc, clique em <strong>Compartilhar</strong> ou abra o dialog de assinatura novamente — vai criar outro token. Limite de usos do link: ~5 (margem pra reload/multi-device), depois também expira.$FAQ$, 4, true),
|
||||
|
||||
(v_doc_id, 'E se o paciente recusar a assinatura?',
|
||||
$FAQ$Tem botão <strong>Recusar</strong> ao lado do <em>Assinar</em>. Clique pede confirmação; ao confirmar, a request fica com <code>status='recusado'</code> com timestamp e IP/UA registrados igual à assinatura. Você vê o status na lista de pendências do doc e na aba do prontuário. Geralmente: ajuste o conteúdo do documento e envie nova solicitação.$FAQ$, 5, true),
|
||||
|
||||
(v_doc_id, 'O paciente pode assinar depois pelo Portal sem precisar do link?',
|
||||
$FAQ$Sim, se ele tem conta de portal. Em <strong>/portal/documentos</strong> aparece a lista de tudo que está pendente pra ele assinar, com KPIs (total, pendentes, assinados, recusados) e botão <strong>Assinar agora</strong> que leva pra mesma página de assinatura. Útil pra pacientes que perderam o link no WhatsApp — eles loga e acha tudo num lugar só.$FAQ$, 6, true),
|
||||
|
||||
(v_doc_id, 'Como compartilho o link com o paciente — tem envio automático?',
|
||||
$FAQ$Hoje o envio é <strong>manual</strong>: o dialog gera a URL, você copia e cola onde quiser (WhatsApp, email, SMS, AirDrop, QR code, link em conversa direta). Envio automático (notificação por WA/email quando signature é criada) está no roadmap, depende do Módulo 6 (notifications factory channel) que ainda não foi implementado.$FAQ$, 7, true),
|
||||
|
||||
(v_doc_id, 'A assinatura tem validade legal mesmo sem certificado ICP-Brasil?',
|
||||
$FAQ$Pra documentos clínicos comuns (TCLE, contrato de prestação, autorizações, declarações entre terapeuta-paciente), <strong>sim</strong>. A assinatura simples com timestamp + hash + IP + UA atende LGPD (Art. 7º I — consentimento explícito) e o Código de Ética do CFP. Pra documentos que exigem certificado ICP-Brasil (notarial, procuração com poderes especiais), use uma plataforma externa de assinatura qualificada — esse fluxo não substitui.$FAQ$, 8, true),
|
||||
|
||||
(v_doc_id, 'O paciente consegue editar o documento antes de assinar?',
|
||||
$FAQ$Não. O paciente <strong>só visualiza</strong> — o PDF é renderizado em iframe e a integridade é garantida pelo hash SHA-256 computado no momento da assinatura. Se o conteúdo precisar mudar, é você que ajusta o documento (editar via template ou regenerar) e envia nova solicitação. A assinatura antiga (se houve) fica registrada com o hash do conteúdo antigo — o doc atual tem hash diferente, mostrando que mudou.$FAQ$, 9, true),
|
||||
|
||||
(v_doc_id, 'Como cancelo uma solicitação de assinatura?',
|
||||
$FAQ$Hoje não há um botão "cancelar" direto na UI. O caminho é: ignore (deixa expirar pelo prazo do link) ou peça pro admin marcar como <code>status='expirado'</code> no banco. Em versões futuras teremos botão de cancelar na lista de pendências do doc.$FAQ$, 10, true),
|
||||
|
||||
(v_doc_id, 'Posso pedir mais de uma pessoa pra assinar o mesmo documento?',
|
||||
$FAQ$Sim. Pra termos com múltiplos signatários (autorização de atendimento de menor com 2 pais, contrato com responsável legal + paciente), adicione cada um como signatário separado no dialog. Cada um vira uma entry em <code>document_signatures</code>. O link público é o mesmo — quando o signatário abre, escolhe quem ele é no select acima dos botões e assina apenas a entry dele. Útil também pra você incluir si mesmo (terapeuta) + paciente num contrato bilateral.$FAQ$, 11, true);
|
||||
|
||||
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
|
||||
END;
|
||||
$IMPORT$;
|
||||
|
||||
COMMIT;
|
||||
@@ -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 há 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;
|
||||
@@ -0,0 +1,168 @@
|
||||
-- Importacao da doc do Cronometro de sessao (Melissa)
|
||||
-- Gerado a partir de development/saas-docs/02-cronometro-melissa.json
|
||||
BEGIN;
|
||||
|
||||
DO $IMPORT$
|
||||
DECLARE
|
||||
v_doc_id uuid;
|
||||
BEGIN
|
||||
-- 1) Cria a doc principal
|
||||
INSERT INTO public.saas_docs (
|
||||
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
|
||||
pagina_path, ordem, ativo, medias
|
||||
) VALUES (
|
||||
'Cronômetro de sessão',
|
||||
$HTML$<h2>Cronômetro de sessão</h2>
|
||||
|
||||
<p>O <strong>cronômetro de sessão</strong> acompanha o tempo decorrido durante o atendimento e é integrado com a agenda. Quando aberto a partir de uma sessão em andamento, ele já vem com o paciente pré-selecionado e dispara automaticamente.</p>
|
||||
|
||||
<h3>1. Três jeitos de abrir</h3>
|
||||
<ul>
|
||||
<li>Pelo botão <strong>⏱</strong> ao lado do relógio gigante do dashboard (abre vazio, escolha o paciente — ou deixe como atividade livre)</li>
|
||||
<li>Pelo botão <strong>⏱</strong> que aparece sobre os cards de sessão em curso na timeline horizontal/vertical do dashboard</li>
|
||||
<li>Pelo CTA <strong>"Iniciar cronômetro"</strong> no card <em>"Próximo paciente"</em> quando a sessão está em andamento</li>
|
||||
</ul>
|
||||
<p>Os dois últimos pré-selecionam o paciente da sessão e disparam o timer automaticamente.</p>
|
||||
|
||||
<h3>2. Sessão em curso na timeline</h3>
|
||||
<p>Quando uma sessão entra em andamento (horário atual entre início e fim do evento), aparece um ícone <strong>⏱</strong> pulsando no canto superior direito do card do evento. O pulso é sutil, em verde — só pra sinalizar que dá pra cronometrar dali.</p>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 8px; background: #6366f1; color: white; padding: 4px 10px; border-radius: 4px; max-width: 320px; position: relative; margin: 12px 0; font-family: 'Segoe UI', sans-serif;">
|
||||
<span style="font-size: 0.85rem; font-weight: 600;">11:00 – Larissa Almeida</span>
|
||||
<span style="position: absolute; top: 3px; right: 3px; width: 22px; height: 22px; display: grid; place-items: center; background: rgba(0,0,0,0.45); border: 1px solid rgba(255,255,255,0.4); border-radius: 999px; color: white; box-shadow: 0 0 0 4px rgba(16,185,129,0.25);">
|
||||
<i class="pi pi-stopwatch" style="font-size: 0.7rem;"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p>Clicar no <strong>⏱</strong> <strong>não abre o evento</strong> — abre o cronômetro pré-configurado pra essa sessão.</p>
|
||||
|
||||
<h3>3. Programado vs tempo real</h3>
|
||||
<p>Quando aberto via timeline ou card "Próximo paciente", o cronômetro mostra o <strong>horário programado original da sessão</strong> sob o select de paciente. Se você abriu depois do horário previsto, aparece um badge laranja <strong>"atrasada X min"</strong>.</p>
|
||||
|
||||
<div style="background: rgba(15,23,42,0.85); color: #cbd5e1; padding: 14px; border-radius: 10px; max-width: 360px; font-family: 'Segoe UI', sans-serif; margin: 12px 0;">
|
||||
<label style="font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.15em; color: rgba(255,255,255,0.5); display: block; margin-bottom: 8px;">Paciente / atividade</label>
|
||||
<div style="background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15); padding: 9px 14px; border-radius: 10px; font-size: 0.9rem;">Larissa Almeida</div>
|
||||
<div style="display: flex; align-items: center; gap: 6px; margin-top: 8px; padding: 4px 0;">
|
||||
<i class="pi pi-calendar" style="font-size: 0.7rem; color: rgba(255,255,255,0.55);"></i>
|
||||
<span style="font-size: 0.78rem; color: rgba(255,255,255,0.7);">Programado: 11:00 – 11:50</span>
|
||||
<span style="margin-left: 4px; padding: 1px 8px; border-radius: 999px; background: rgba(251,146,60,0.18); color: rgb(253,186,116); font-size: 0.7rem; font-weight: 500; border: 1px solid rgba(251,146,60,0.35);">atrasada 8 min</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>⚠️ <strong>O cronômetro NÃO desconta o tempo de atraso automaticamente.</strong> Ele conta a duração configurada cheia (50min padrão) a partir do clique. A info "atrasada" é só pra você decidir se quer encerrar antes ou estender.</p>
|
||||
|
||||
<h3>4. Anatomia do dialog</h3>
|
||||
<ul>
|
||||
<li><strong>Header:</strong> rótulo "Cronômetro" + status (<em>Pronto</em> / <em>Em andamento</em> / <em>Pausado</em>)</li>
|
||||
<li><strong>Botão Minimizar:</strong> recolhe pro chip no dock</li>
|
||||
<li><strong>Botão X (Encerrar sem salvar):</strong> descarta a sessão (com confirmação se houver atividade)</li>
|
||||
<li><strong>Select de paciente:</strong> pode trocar manualmente; opção <em>"— Atividade livre"</em> pra usos sem paciente</li>
|
||||
<li><strong>Programado + badge de atraso:</strong> só aparece quando aberto via evento da agenda</li>
|
||||
<li><strong>Display gigante mm:ss:</strong> vira vermelho quando passa do tempo planejado (mostra <code>-mm:ss</code>)</li>
|
||||
<li><strong>±5 min:</strong> estende ou encurta o tempo configurado a qualquer momento</li>
|
||||
<li><strong>Botão grande inferior:</strong> ▶ Começar / ⏹ Parar</li>
|
||||
</ul>
|
||||
|
||||
<h3>5. Minimizar e restaurar</h3>
|
||||
<p>Click no <strong>botão minimizar</strong> (ou click fora do dialog) <strong>recolhe</strong> o cronômetro pra um <strong>chip flutuante no dock</strong> (canto inferior esquerdo, ao lado do ψ). O timer continua rodando em background. Click no chip restaura o dialog em tela cheia.</p>
|
||||
|
||||
<div style="display: inline-flex; align-items: center; gap: 10px; padding: 8px 14px 8px 12px; background: rgba(15,23,42,0.85); border: 1px solid rgba(255,255,255,0.18); border-radius: 999px; color: #cbd5e1; font-family: 'Segoe UI', sans-serif; box-shadow: 0 8px 24px rgba(0,0,0,0.25); margin: 12px 0;">
|
||||
<i class="pi pi-stopwatch" style="font-size: 0.85rem; color: #6ee7b7;"></i>
|
||||
<span style="font-variant-numeric: tabular-nums; font-weight: 500; font-size: 0.85rem;">48:13</span>
|
||||
<span style="font-size: 0.72rem; color: rgba(255,255,255,0.6); padding-left: 6px; border-left: 1px solid rgba(255,255,255,0.18);">Larissa Almeida</span>
|
||||
</div>
|
||||
|
||||
<p>Em mobile (telas <768px), o chip mostra só o ícone + tempo — sem o nome do paciente — pra caber no dock estreito. O nome continua acessível ao restaurar.</p>
|
||||
|
||||
<h3>6. Parar (salva) vs Fechar (descarta)</h3>
|
||||
<p>Duas ações diferentes pra terminar — a escolha importa:</p>
|
||||
<ul>
|
||||
<li><strong>⏹ Parar</strong> (botão grande inferior): encerra a contagem e <strong>SALVA o tempo decorrido no banco</strong> (evento <code>session-end</code> com elapsed em segundos). Caminho normal de fim de sessão.</li>
|
||||
<li><strong>X Encerrar sem salvar</strong> (header): descarta. Pede <strong>confirmação</strong> se há sessão em andamento ou tempo decorrido — não fecha por acidente. Se o cronômetro está limpo (não iniciado, sem tempo), fecha direto.</li>
|
||||
<li><strong>Click fora / Minimizar</strong>: NÃO encerra. Esconde o dialog e mantém o timer rodando como chip no dock.</li>
|
||||
</ul>
|
||||
|
||||
<h3>7. Quando o tempo acaba</h3>
|
||||
<p>Aos <strong>50 minutos cronometrados</strong> (ou conforme configurado), o sistema toca um <strong>som curto</strong> uma única vez. O display vira <strong>vermelho</strong> e continua contando em negativo (mostra <code>-mm:ss</code>). <strong>Não há corte automático</strong> — você decide quando parar.</p>
|
||||
|
||||
<p>O toque pode ser trocado em <strong>Configurações → Cronômetro → Som de término</strong>. Opções: sino, gong, soft, silêncio.</p>
|
||||
|
||||
<h3>8. Persistência (reload-safe)</h3>
|
||||
<p>Se você fechar a aba ou recarregar o navegador com cronômetro ativo, ao voltar o sistema <strong>retoma exatamente de onde parou</strong> — descontando automaticamente o tempo passado entre fechar e abrir. O snapshot fica no <code>localStorage</code> do navegador, atualizado a cada mudança de estado.</p>
|
||||
|
||||
<p><strong>Limite de segurança:</strong> se passar de 24h sem voltar à aba, o restore não acumula o tempo perdido (proteção contra mudanças do relógio do sistema).</p>
|
||||
|
||||
<h3>9. Cronômetro já ativo</h3>
|
||||
<p>Existe <strong>um cronômetro por vez</strong>. Se você clicar no <strong>⏱</strong> de outra sessão enquanto há cronômetro rodando, o sistema mostra um toast <strong>"Cronômetro já ativo"</strong> com o nome do paciente atual — <strong>e não troca</strong>. Pare o cronômetro atual antes de iniciar outro.</p>
|
||||
|
||||
<h3>10. Atividade livre (sem paciente)</h3>
|
||||
<p>Você pode abrir o cronômetro sem paciente (botão ⏱ do dashboard, sem clicar em evento específico) e selecionar <em>"— Atividade livre (sem paciente)"</em> no dropdown. Útil pra:</p>
|
||||
<ul>
|
||||
<li>Pausa cronometrada</li>
|
||||
<li>Pomodoro pessoal</li>
|
||||
<li>Atendimento informal não cadastrado</li>
|
||||
</ul>
|
||||
<p>Atividade livre <strong>não emite session-end</strong> ao parar (não há paciente pra vincular o tempo).</p>
|
||||
|
||||
<h3>⚠️ Notas pro desenvolvedor</h3>
|
||||
<p>Atualmente o componente <code>MelissaCronometro.vue</code> <strong>não tem atributos <code>id</code></strong> em seus elementos. Para o sistema de highlight da ajuda funcionar (links <code>data-highlight</code>), sugere-se adicionar:</p>
|
||||
<ul>
|
||||
<li><code>id="crono-trigger-hero"</code> no botão ⏱ ao lado do relógio (<code>MelissaHeroClock.vue</code>)</li>
|
||||
<li><code>id="crono-trigger-timeline"</code> nos botões ⏱ overlay (<code>MelissaTimelineHoje.vue</code>)</li>
|
||||
<li><code>id="crono-dialog"</code> no panel principal (<code>.mc-panel</code>)</li>
|
||||
<li><code>id="crono-stop-btn"</code> no botão Parar (caminho do salvamento)</li>
|
||||
<li><code>id="crono-close-btn"</code> no X (caminho do descarte)</li>
|
||||
</ul>$HTML$,
|
||||
'Sessão',
|
||||
true,
|
||||
'usuario',
|
||||
'/melissa',
|
||||
2,
|
||||
true,
|
||||
'[{"tipo": "imagem", "url": ""}]'::jsonb
|
||||
)
|
||||
RETURNING id INTO v_doc_id;
|
||||
|
||||
-- 2) Insere os 12 FAQ items vinculados
|
||||
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
|
||||
(v_doc_id, 'Como começo o cronômetro da Larissa que chegou agora pra sessão?',
|
||||
$FAQ$Quando o horário programado da Larissa estiver dentro da janela do evento (já começou na agenda), aparece um botão <strong>⏱</strong> pulsando em verde no canto superior direito do card da sessão na timeline. Clique nele — o cronômetro abre com a Larissa pré-selecionada e <strong>já começa a contar automaticamente</strong>. Alternativa: clique no botão <em>"Iniciar cronômetro"</em> no card "Próximo paciente" do dashboard (mesmo efeito).$FAQ$, 0, true),
|
||||
|
||||
(v_doc_id, 'O cronômetro continua se eu fechar a aba do navegador?',
|
||||
$FAQ$<strong>Sim.</strong> O estado é salvo no <code>localStorage</code> a cada mudança (paciente, play, pause, ajustes). Ao reabrir a aba (ou recarregar), o cronômetro retoma do ponto correto — o tempo passado entre fechar e abrir é descontado automaticamente. Limite: se passar de 24h, o sistema não acumula esse tempo (proteção contra mudanças do relógio do sistema).$FAQ$, 1, true),
|
||||
|
||||
(v_doc_id, 'Cliquei no X com sessão rodando, perdi o tempo?',
|
||||
$FAQ$Não, o sistema <strong>pede confirmação antes</strong>. Quando há sessão em andamento ou tempo decorrido sem salvar, aparece um diálogo <em>"Encerrar sessão sem salvar?"</em>. Você precisa clicar em <strong>"Encerrar sem salvar"</strong> (botão vermelho) pra confirmar o descarte. Se o cronômetro estiver limpo (não iniciado, sem tempo), o X fecha direto — não há nada pra preservar.$FAQ$, 2, true),
|
||||
|
||||
(v_doc_id, 'Posso ter dois cronômetros rodando ao mesmo tempo?',
|
||||
$FAQ$Não. Existe <strong>um cronômetro por vez</strong>. Se você clicar no <strong>⏱</strong> de outra sessão enquanto já há um cronômetro ativo, o sistema mostra um toast <em>"Cronômetro já ativo — sessão de X em andamento"</em> e <strong>não troca</strong>. Pare ou descarte o atual antes de iniciar outro.$FAQ$, 3, true),
|
||||
|
||||
(v_doc_id, 'O que significa o badge laranja "atrasada 8 min"?',
|
||||
$FAQ$Significa que <strong>o cronômetro foi aberto 8 minutos depois do horário programado</strong> da sessão na agenda. Por exemplo: sessão programada pra 11:00, você inicia o cronômetro às 11:08. O badge é apenas informativo — o cronômetro continua contando a duração configurada cheia (50min padrão) a partir do clique. Você decide se vai encerrar antes pra terminar no horário previsto ou deixar rodar pra dar a sessão completa.$FAQ$, 4, true),
|
||||
|
||||
(v_doc_id, 'O cronômetro desconta o tempo de atraso automaticamente?',
|
||||
$FAQ$<strong>Não.</strong> A decisão fica com você. Cada clínica e cada terapeuta tem uma política diferente pra atraso (alguns dão sessão cheia, outros encerram no horário programado, outros estendem). O cronômetro mostra a info do atraso pra você decidir, mas conta sempre a duração configurada cheia a partir do clique.$FAQ$, 5, true),
|
||||
|
||||
(v_doc_id, 'Que som toca quando o tempo acaba?',
|
||||
$FAQ$Por padrão, um <strong>som de sino curto</strong>, uma única vez. Você pode trocar em <strong>Configurações → Cronômetro → Som de término</strong>. Opções: sino, gong, soft, silêncio. O som toca <strong>exatamente na transição</strong> de tempo positivo pra zero/negativo — não repete. Depois disso o display continua contando em negativo (vermelho) até você parar.$FAQ$, 6, true),
|
||||
|
||||
(v_doc_id, 'Como adiciono mais tempo na sessão sem reiniciar?',
|
||||
$FAQ$Use os botões <strong>+5 min</strong> e <strong>-5 min</strong> ao redor do display gigante. Funcionam a qualquer momento — antes, durante ou depois do tempo acabar. Cada clique soma ou desconta 5 minutos. Se o tempo já está negativo (passou do limite), +5min volta a contagem pra positivo.$FAQ$, 7, true),
|
||||
|
||||
(v_doc_id, 'Onde fica salvo o tempo final da sessão?',
|
||||
$FAQ$Quando você clica em <strong>⏹ Parar</strong>, o tempo cronometrado é gravado no banco vinculado à sessão da agenda (na tabela <code>agenda_eventos</code>, campo de duração real). Esse caminho é o oficial — <strong>fechar pelo X descarta sem salvar</strong>. Sessões com menos de 5 segundos cronometrados são ignoradas (proteção contra start/stop acidentais).$FAQ$, 8, true),
|
||||
|
||||
(v_doc_id, 'Posso usar o cronômetro pra coisas que não são sessão de paciente?',
|
||||
$FAQ$Sim. Selecione <em>"— Atividade livre (sem paciente)"</em> no dropdown de paciente. Útil pra pausa cronometrada, pomodoro pessoal, atendimento informal não cadastrado. Atividade livre <strong>não dispara session-end</strong> ao parar — não há paciente pra vincular o tempo no DB.$FAQ$, 9, true),
|
||||
|
||||
(v_doc_id, 'Como minimizo o cronômetro pra continuar trabalhando?',
|
||||
$FAQ$Clique no botão <strong>minimizar</strong> no header (ícone <code>—</code>) ou simplesmente <strong>clique fora do dialog</strong>. O cronômetro vira um chip flutuante no dock (canto inferior esquerdo, ao lado do ψ) e continua contando em background. Pra restaurar: clique no chip. Em mobile, o chip mostra só ícone + tempo (sem nome) pra caber no dock estreito.$FAQ$, 10, true),
|
||||
|
||||
(v_doc_id, 'Como mudo o paciente no cronômetro já aberto?',
|
||||
$FAQ$Basta clicar no <strong>select de paciente</strong> e escolher outro. A troca é imediata — não reinicia o tempo decorrido (a contagem continua igual). Útil quando você abriu o cronômetro no paciente errado e quer corrigir sem perder o tempo já contado. Mas atenção: o <em>session-end</em> ao parar vai vincular o tempo ao paciente que estiver selecionado <em>no momento da parada</em>.$FAQ$, 11, true);
|
||||
|
||||
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
|
||||
END;
|
||||
$IMPORT$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,173 @@
|
||||
-- Importacao da doc da aba Documentos do paciente (Fase 2)
|
||||
-- Gerado a partir de development/saas-docs/03-documentos-paciente-melissa.json
|
||||
BEGIN;
|
||||
|
||||
DO $IMPORT$
|
||||
DECLARE
|
||||
v_doc_id uuid;
|
||||
BEGIN
|
||||
INSERT INTO public.saas_docs (
|
||||
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
|
||||
pagina_path, ordem, ativo, medias
|
||||
) VALUES (
|
||||
'Documentos do paciente',
|
||||
$HTML$<h2>Documentos do paciente</h2>
|
||||
|
||||
<p>A aba <strong>Documentos</strong> do prontuário (em <code>/melissa/paciente?id=...&tab=documentos</code>) centraliza tudo que está vinculado àquele paciente: arquivos enviados por upload, documentos gerados a partir de templates (atestados, declarações, recibos, laudos…), e tudo que precisa ser compartilhado ou assinado.</p>
|
||||
|
||||
<h3>1. Layout 2-col</h3>
|
||||
<p>A página tem 2 colunas:</p>
|
||||
<ul>
|
||||
<li><strong>Sidebar esquerda (~240px):</strong> lista de tipos de documento com contadores. Click num tipo filtra a lista. "Todos" mostra tudo.</li>
|
||||
<li><strong>Main direita:</strong> grid de cards dos documentos do tipo selecionado, com paginação a partir de 12 itens.</li>
|
||||
</ul>
|
||||
<p>No <strong>mobile</strong> (<1024px), a sidebar vira um drawer acessado pelo botão "Tipos" no header.</p>
|
||||
|
||||
<h3>2. Toolbar (header)</h3>
|
||||
<p>3 botões no topo:</p>
|
||||
<ul>
|
||||
<li><strong>↻ Atualizar:</strong> refetch da lista (ícone spinner quando carregando)</li>
|
||||
<li><strong>📄 Gerar:</strong> abre o dialog de geração a partir de template (vide seção 5)</li>
|
||||
<li><strong>⬆ Upload</strong> (botão primário): abre o dialog de envio de arquivo (vide seção 3)</li>
|
||||
</ul>
|
||||
|
||||
<h3>3. Upload de arquivo</h3>
|
||||
<p>Click no botão <strong>Upload</strong> abre um dialog que aceita:</p>
|
||||
<ul>
|
||||
<li><strong>Drag-and-drop</strong> ou seleção manual</li>
|
||||
<li>Formatos: PDF, imagens (JPG, PNG, WebP), Word, Excel, texto</li>
|
||||
<li>Metadados opcionais: <strong>tipo</strong>, <strong>categoria</strong>, <strong>descrição</strong>, <strong>tags</strong>, <strong>visibilidade</strong> (privado / compartilhado supervisor / compartilhado portal paciente)</li>
|
||||
</ul>
|
||||
<p>Após o upload, o arquivo aparece na lista do tipo escolhido (ou "Outro" se você não selecionou).</p>
|
||||
|
||||
<h3>4. Tipos de documento (sidebar)</h3>
|
||||
<p>Cada documento é classificado em um tipo. Tipos disponíveis:</p>
|
||||
<ul>
|
||||
<li><strong>Laudo</strong> — laudo psicológico, parecer</li>
|
||||
<li><strong>Atestado</strong> — atestado psicológico</li>
|
||||
<li><strong>Declaração</strong> — comparecimento, início de tratamento, encaminhamento</li>
|
||||
<li><strong>Recibo</strong> — recibos de pagamento gerados</li>
|
||||
<li><strong>Receita</strong> — receituários (uso raro em psicologia)</li>
|
||||
<li><strong>Exame</strong> — laudos/resultados de exames trazidos pelo paciente</li>
|
||||
<li><strong>Termo assinado</strong> — TCLE, autorizações</li>
|
||||
<li><strong>Relatório externo</strong> — relatórios de acompanhamento gerados</li>
|
||||
<li><strong>Identidade</strong> — RG, CPF, CNH (cópias)</li>
|
||||
<li><strong>Convênio</strong> — carteirinhas, autorizações de convênio</li>
|
||||
<li><strong>Outro</strong> — fallback pra tudo que não se encaixa nos tipos acima</li>
|
||||
</ul>
|
||||
<p>O contador ao lado de cada tipo mostra quantos docs daquele tipo o paciente tem. Tipos vazios ficam com opacidade reduzida.</p>
|
||||
|
||||
<h3>5. Gerar a partir de template</h3>
|
||||
<p>Click no botão <strong>Gerar</strong> abre o <em>DocumentGenerateDialog</em> em 3 passos:</p>
|
||||
<ol>
|
||||
<li><strong>Selecionar template:</strong> grid com todos os templates ativos (globais + do tenant). Click num card seleciona.</li>
|
||||
<li><strong>Editar variáveis:</strong> os campos do template aparecem com FloatLabel. Variáveis que vêm do sistema (nome do paciente, CRP do terapeuta, CNPJ da clínica etc) já vêm preenchidas automaticamente. Banner no topo conta "X de Y preenchidos". Campos vazios mostram um hint embaixo explicando onde cadastrar o dado (ex: <em>"Perfil → Registro Profissional"</em>).</li>
|
||||
<li><strong>Preview:</strong> iframe sandboxed renderizando o HTML do template com as vars substituídas. Daqui você pode voltar pra editar, só baixar o PDF (sem salvar no sistema), ou salvar como documento do paciente.</li>
|
||||
</ol>
|
||||
|
||||
<div style="background: rgba(34,197,94,0.08); border: 1px solid rgba(34,197,94,0.25); border-radius: 10px; padding: 12px 14px; margin: 14px 0; font-size: 0.85rem; color: var(--text-color);">
|
||||
<strong>💡 Auto-fill cobre:</strong> dados do paciente, terapeuta (incluindo registro profissional formatado tipo "CRP 12345/SP"), clínica/tenant (incluindo CNPJ formatado), data atual em formato curto e por extenso, e — se a sessão for vinculada — valor da sessão em número e por extenso.
|
||||
</div>
|
||||
|
||||
<h3>6. Editar um documento gerado (re-edição in-place)</h3>
|
||||
<p>Documentos gerados a partir de template podem ser <strong>re-editados</strong> mantendo o mesmo registro (ID, audit trail e link com o paciente preservados). Click em <strong>Editar</strong> no card do doc ou na sidebar do preview:</p>
|
||||
|
||||
<ol>
|
||||
<li>O sistema busca o template original + os valores que você usou na primeira geração</li>
|
||||
<li>Abre o dialog em modo edição (header amber "Editar documento") pulando direto pro passo 2 (variáveis pré-preenchidas)</li>
|
||||
<li>Você ajusta o que precisar → Preview → <strong>Substituir documento</strong></li>
|
||||
<li>O PDF é regenerado e substitui o anterior no Storage; o doc fica com o mesmo ID, audit trail intacto</li>
|
||||
</ol>
|
||||
|
||||
<p><strong>Documento legado</strong> (sem registro de geração ou que era um upload): o dialog mostra um toast e cai no fluxo normal de "selecione um template". Ao salvar, ele linka o doc existente ao novo template/valores.</p>
|
||||
|
||||
<h3>7. Preview do documento</h3>
|
||||
<p>Click num card abre o <em>DocumentPreviewDialog</em>:</p>
|
||||
<ul>
|
||||
<li><strong>Preview inline:</strong> iframe pra PDF, imagem renderizada direto, fallback "Preview não disponível" pra outros formatos</li>
|
||||
<li><strong>Sidebar de detalhes</strong> (direita): tipo, categoria, visibilidade, descrição, tags</li>
|
||||
<li><strong>5 botões de ação</strong> no rodapé da sidebar:
|
||||
<ul>
|
||||
<li><strong>Baixar</strong> — download direto do arquivo</li>
|
||||
<li><strong>Editar</strong> — abre o generate dialog em modo edição (seção 6)</li>
|
||||
<li><strong>Compartilhar</strong> — gera link compartilhável (seção 8)</li>
|
||||
<li><strong>Assinar</strong> — fluxo de assinatura eletrônica (seção 9)</li>
|
||||
<li><strong>Excluir</strong> (vermelho) — soft-delete com confirmação</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3>8. Compartilhar</h3>
|
||||
<p>Gera um link público temporário pro paciente acessar o documento sem precisar de login. Configurável:</p>
|
||||
<ul>
|
||||
<li>Tempo de expiração (1h, 24h, 7 dias, custom)</li>
|
||||
<li>Senha opcional</li>
|
||||
<li>Permitir download ou só visualização</li>
|
||||
</ul>
|
||||
<p>O status compartilhado fica visível na sidebar de detalhes do preview.</p>
|
||||
|
||||
<h3>9. Assinar</h3>
|
||||
<p>Fluxo de assinatura eletrônica (modal). O documento original recebe uma <strong>página adicional de assinatura</strong> com timestamp e identificação do signatário. A assinatura é registrada em <code>document_signatures</code> com hash do conteúdo original (proof of integrity).</p>
|
||||
|
||||
<h3>10. Excluir e recuperar</h3>
|
||||
<p>Excluir é <strong>soft-delete</strong>: o documento ganha <code>deleted_at</code> mas o arquivo permanece no Storage e o registro fica preservado por <strong>5 anos</strong> (compliance LGPD/CFP). Pra recuperar, vá em <strong>Configurações → Lixo de documentos</strong>.</p>
|
||||
|
||||
<h3>⚠️ Notas pro desenvolvedor</h3>
|
||||
<p>O componente <code>MelissaPatientDocuments.vue</code> reusa do <code>features/documents</code>:</p>
|
||||
<ul>
|
||||
<li><code>useDocuments</code> — composable de fetch/CRUD/URLs assinadas</li>
|
||||
<li><code>DocumentCard</code>, <code>DocumentUploadDialog</code>, <code>DocumentPreviewDialog</code>, <code>DocumentGenerateDialog</code>, <code>DocumentSignatureDialog</code>, <code>DocumentShareDialog</code></li>
|
||||
</ul>
|
||||
<p>O linkage <code>document_generated.documento_id</code> (FK pra <code>documents</code>) é o que viabiliza a re-edição in-place. Docs gerados antes da migration de linkage precisam do backfill SQL em <code>database-novo/tmp/backfill-document-generated-link.sql</code>.</p>$HTML$,
|
||||
'Documentos',
|
||||
true,
|
||||
'usuario',
|
||||
'/melissa/paciente',
|
||||
3,
|
||||
true,
|
||||
'[{"tipo": "imagem", "url": ""}]'::jsonb
|
||||
)
|
||||
RETURNING id INTO v_doc_id;
|
||||
|
||||
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
|
||||
(v_doc_id, 'Como envio um documento que já existe (PDF/imagem do paciente)?',
|
||||
$FAQ$Na aba <strong>Documentos</strong> do prontuário, click no botão <strong>Upload</strong> (azul, no canto superior direito). Você pode arrastar o arquivo pra área do dialog ou clicar pra selecionar. Antes de enviar, preencha o tipo, descrição e tags se quiser — assim o doc já vai pra categoria certa na sidebar.$FAQ$, 0, true),
|
||||
|
||||
(v_doc_id, 'Como gero um documento (atestado, declaração, recibo) a partir de template?',
|
||||
$FAQ$Click no botão <strong>Gerar</strong> no header da aba Documentos do paciente. O dialog abre em 3 passos: (1) escolha o template, (2) confira as variáveis pré-preenchidas (e ajuste se necessário), (3) preview e <em>Salvar documento</em>. O PDF é gerado e salvo automaticamente no prontuário.$FAQ$, 1, true),
|
||||
|
||||
(v_doc_id, 'As variáveis (CRP, nome, CNPJ etc) preenchem sozinhas mesmo?',
|
||||
$FAQ$Sim, sempre que possível. O sistema busca: dados do paciente (nome, CPF, RG, endereço, telefone, email…), do terapeuta (nome, email, telefone, e o registro profissional formatado tipo <em>CRP 12345/SP</em>), da clínica (nome, endereço, telefone, CNPJ formatado), data atual em formato curto e por extenso. Se você abriu o gerador a partir de uma sessão, os dados da sessão (valor, data) também entram. Campos vazios mostram embaixo um hint dizendo onde cadastrar o dado faltante.$FAQ$, 2, true),
|
||||
|
||||
(v_doc_id, 'Posso editar um documento gerado sem refazer tudo do zero?',
|
||||
$FAQ$Sim. Click em <strong>Editar</strong> no card do documento (ou na sidebar do preview). O dialog abre em <em>modo edição</em> com o template original já selecionado e <strong>todos os valores que você usou anteriormente preenchidos</strong>. Você ajusta o que precisa, confere o preview e click em <em>Substituir documento</em>. O PDF é regenerado e substitui o anterior, mas o ID e o audit trail do doc continuam os mesmos.$FAQ$, 3, true),
|
||||
|
||||
(v_doc_id, 'Posso editar um documento que foi feito por upload (não por template)?',
|
||||
$FAQ$Sim, mas o fluxo é diferente: como não há template original, o sistema mostra um aviso e abre o dialog em modo "selecione um template". Ao salvar, ele <strong>substitui o arquivo enviado por um PDF gerado</strong> e linka ao novo template. Útil pra "converter" um upload manual em algo padronizado. Se você só quer trocar o arquivo, exclua o doc e faça upload do novo.$FAQ$, 4, true),
|
||||
|
||||
(v_doc_id, 'Como compartilho um documento com o paciente sem ele precisar logar?',
|
||||
$FAQ$No preview, click em <strong>Compartilhar</strong>. Um dialog gera um link público temporário com opção de tempo de expiração (1h, 24h, 7 dias, custom) e senha opcional. O paciente acessa pelo link, sem login. O status fica visível na sidebar de detalhes do doc.$FAQ$, 5, true),
|
||||
|
||||
(v_doc_id, 'Como assino eletronicamente um documento?',
|
||||
$FAQ$No preview, click em <strong>Assinar</strong>. O fluxo adiciona uma página de assinatura ao PDF com timestamp e identificação. A assinatura é registrada com hash do conteúdo original — qualquer alteração posterior invalida a integridade. Ideal pra laudos, declarações e atestados que precisam de validade legal.$FAQ$, 6, true),
|
||||
|
||||
(v_doc_id, 'Excluí um documento por engano, dá pra recuperar?',
|
||||
$FAQ$Sim. Exclusão é <strong>soft-delete</strong> — o documento ganha um marcador <code>deleted_at</code> mas continua no banco e o arquivo permanece no Storage. Pra recuperar, vá em <strong>Configurações → Lixo de documentos</strong>. O período de retenção é de <strong>5 anos</strong> (compliance LGPD e regulamentação CFP), depois o arquivo é purgado permanentemente.$FAQ$, 7, true),
|
||||
|
||||
(v_doc_id, 'Por que alguns documentos aparecem na categoria "Outro"?',
|
||||
$FAQ$Documentos enviados por upload sem tipo definido caem em "Outro" automaticamente. Documentos gerados a partir de templates cujo tipo não está mapeado pras categorias padrão (declarações, atestados, laudos, etc) também — exemplos: contrato de prestação de serviços, autorização para gravação, termo de consentimento. Você pode mover o doc pra outra categoria editando o tipo na hora do upload ou via menu de ações no card.$FAQ$, 8, true),
|
||||
|
||||
(v_doc_id, 'Quais formatos de arquivo posso fazer upload?',
|
||||
$FAQ$PDF, imagens (JPG, PNG, WebP, GIF), documentos Office (DOCX, XLSX, PPTX), texto simples (TXT, CSV) e formatos compactados (ZIP). Pra qualquer formato fora dessa lista, salve como PDF antes. O preview inline só funciona pra PDF e imagens — outros formatos mostram a opção "Baixar arquivo" no lugar.$FAQ$, 9, true),
|
||||
|
||||
(v_doc_id, 'Como o sistema garante que o documento não vaza pra outros profissionais?',
|
||||
$FAQ$Cada documento tem um campo de <strong>visibilidade</strong>: <em>Privado</em> (só você vê), <em>Compartilhado com supervisor</em> (você + seu supervisor) ou <em>Compartilhado com portal do paciente</em> (o paciente também vê pelo portal). O default é Privado. RLS (Row Level Security) no banco bloqueia leitura por terceiros, independente da visibilidade. URLs do Storage são assinadas e expiram em 1h.$FAQ$, 10, true),
|
||||
|
||||
(v_doc_id, 'Os botões da sidebar do preview (Baixar/Editar/Compartilhar/Assinar/Excluir) não funcionavam, foi corrigido?',
|
||||
$FAQ$Sim. Bug conhecido até 2026-05-22: o <code>DocumentPreviewDialog</code> emitia os 5 eventos mas o componente pai não os escutava, então nada acontecia ao clicar. Agora todos os 5 botões funcionam normalmente e o de Editar abre o dialog de geração em modo edição.$FAQ$, 11, true);
|
||||
|
||||
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
|
||||
END;
|
||||
$IMPORT$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,122 @@
|
||||
-- Importacao da doc da pagina de Templates de documentos (Fase 2)
|
||||
-- Gerado a partir de development/saas-docs/04-documentos-templates-melissa.json
|
||||
BEGIN;
|
||||
|
||||
DO $IMPORT$
|
||||
DECLARE
|
||||
v_doc_id uuid;
|
||||
BEGIN
|
||||
INSERT INTO public.saas_docs (
|
||||
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
|
||||
pagina_path, ordem, ativo, medias
|
||||
) VALUES (
|
||||
'Templates de documentos',
|
||||
$HTML$<h2>Templates de documentos</h2>
|
||||
|
||||
<p>A página <strong>Templates de documentos</strong> (acessível pelo menu Prontuários → Templates de documentos, ou diretamente em <code>/melissa/documentos-templates</code>) é onde você gerencia os modelos usados pra gerar atestados, declarações, recibos, laudos e outros documentos clínicos.</p>
|
||||
|
||||
<h3>1. Globais vs Tenant (Seus templates)</h3>
|
||||
<p>A lista é dividida em 2 grupos:</p>
|
||||
<ul>
|
||||
<li><strong>Templates padrão (globais)</strong> — vêm pré-instalados com o sistema (Declaração de Comparecimento, Atestado Psicológico, Recibo de Pagamento, Laudo Psicológico, Parecer, Encaminhamento, etc). São <strong>read-only</strong> — você não pode editar nem desativar, mas pode duplicar pra personalizar.</li>
|
||||
<li><strong>Seus templates (tenant)</strong> — os que você criou ou duplicou. Editáveis, removíveis (desativação soft-delete).</li>
|
||||
</ul>
|
||||
<p>Todos os templates ativos do tenant (globais + seus) ficam disponíveis na hora de gerar um documento pro paciente.</p>
|
||||
|
||||
<h3>2. Lista de templates</h3>
|
||||
<p>Cards em grid mostrando: nome, tipo, descrição, badge "padrão" pros globais. No card de cada template do tenant há um menu de 3 pontos com: <strong>Duplicar</strong>, <strong>Editar</strong>, <strong>Desativar</strong>. Pros globais, só <strong>Duplicar</strong> (e click no card abre a Preview).</p>
|
||||
|
||||
<h3>3. Preview de template global (read-only)</h3>
|
||||
<p>Click num template padrão abre a Preview — iframe sandbox renderizando o HTML completo (cabeçalho + corpo + rodapé) com estilos de A4 simulando o PDF final. Header tem botão <strong>Duplicar</strong> pra você levar pros seus templates.</p>
|
||||
|
||||
<h3>4. Criar novo template</h3>
|
||||
<p>Botão <strong>+ Novo template</strong> abre o editor em modo "create". Campos:</p>
|
||||
<ul>
|
||||
<li><strong>Nome</strong> e <strong>tipo</strong> (declaração, atestado, recibo, laudo, etc) — define a categoria do documento gerado</li>
|
||||
<li><strong>Descrição</strong> opcional — aparece na lista</li>
|
||||
<li><strong>Cabeçalho</strong> (top fixo) — geralmente nome da clínica, endereço, CNPJ</li>
|
||||
<li><strong>Corpo</strong> (conteúdo principal) — o texto do documento com variáveis interpoladas</li>
|
||||
<li><strong>Rodapé</strong> (bottom fixo) — assinatura, contato, observações</li>
|
||||
</ul>
|
||||
|
||||
<h3>5. Editor rich-text + variáveis</h3>
|
||||
<p>Cada bloco (cabeçalho/corpo/rodapé) tem editor WYSIWYG com formatação, listas, tabelas e inserção de imagens. Ao clicar no botão de <strong>variáveis</strong>, abre um menu com todas as variáveis disponíveis. Click numa insere <code>{{nome_da_variavel}}</code> no cursor.</p>
|
||||
|
||||
<div style="background: rgba(99,102,241,0.08); border: 1px solid rgba(99,102,241,0.25); border-radius: 10px; padding: 12px 14px; margin: 14px 0; font-size: 0.85rem; color: var(--text-color);">
|
||||
<strong>💡 Variáveis disponíveis:</strong> <code>{{paciente_nome}}</code>, <code>{{paciente_cpf}}</code>, <code>{{paciente_rg}}</code>, <code>{{paciente_email}}</code>, <code>{{terapeuta_nome}}</code>, <code>{{terapeuta_registro}}</code> (CRP 12345/SP formatado), <code>{{terapeuta_telefone}}</code>, <code>{{clinica_nome}}</code>, <code>{{clinica_cnpj}}</code>, <code>{{data_atual}}</code>, <code>{{data_atual_extenso}}</code>, e — se gerado a partir de sessão — <code>{{valor}}</code>, <code>{{valor_extenso}}</code>, <code>{{data_sessao}}</code>. Lista completa no dropdown do editor.
|
||||
</div>
|
||||
|
||||
<h3>6. Mobile (drawer pros templates)</h3>
|
||||
<p>Em telas <1024px a lista vira um drawer com botão "Templates" no header. Click num item fecha o drawer e mostra o preview/editor ocupando a tela toda.</p>
|
||||
|
||||
<h3>7. Duplicar</h3>
|
||||
<p>Duplicar copia o template (incluindo cabeçalho, corpo, rodapé e variáveis) pra <em>Seus templates</em> com sufixo <em>"(cópia)"</em> no nome. Você edita à vontade depois.</p>
|
||||
|
||||
<h3>8. Desativar (soft-delete)</h3>
|
||||
<p>Templates do tenant podem ser <strong>desativados</strong> (não excluídos). Ficam marcados com <code>ativo = false</code> e somem da lista padrão e do dropdown de geração — mas o registro permanece no banco, e documentos antigos gerados a partir desse template continuam acessíveis. Pra reativar, marque "incluir desativados" no filtro (futuro — atualmente só via DB).</p>
|
||||
|
||||
<h3>9. Tipos de template</h3>
|
||||
<p>Cada template tem um <strong>tipo</strong>. O tipo determina automaticamente qual categoria o documento gerado terá no prontuário do paciente:</p>
|
||||
<ul>
|
||||
<li><code>declaracao_comparecimento</code>, <code>declaracao_inicio_tratamento</code>, <code>encaminhamento</code> → categoria <strong>Declaração</strong></li>
|
||||
<li><code>atestado_psicologico</code> → categoria <strong>Atestado</strong></li>
|
||||
<li><code>laudo_psicologico</code>, <code>parecer_psicologico</code> → categoria <strong>Laudo</strong></li>
|
||||
<li><code>recibo_pagamento</code> → categoria <strong>Recibo</strong></li>
|
||||
<li><code>relatorio_acompanhamento</code> → categoria <strong>Relatório externo</strong></li>
|
||||
<li>Outros tipos (<code>termo_consentimento</code>, <code>contrato_servicos</code>, <code>autorizacao_*</code>, <code>outro</code>) → categoria <strong>Outro</strong></li>
|
||||
</ul>
|
||||
|
||||
<h3>⚠️ Notas pro desenvolvedor</h3>
|
||||
<p>O componente <code>MelissaDocumentosTemplates.vue</code> reusa <code>useDocumentTemplates</code> + <code>DocumentTemplateEditor</code>. A lista de tipos vem do composable (<code>TIPOS_TEMPLATE</code>). O mapeamento tipo de template → tipo do documento gerado vive em <code>DocumentGenerate.service.js</code> (<code>TEMPLATE_TYPE_TO_DOC_TYPE</code>). RLS no banco: templates globais (<code>is_global = true</code>) tem leitura aberta; templates do tenant respeitam <code>tenant_id</code>.</p>$HTML$,
|
||||
'Documentos',
|
||||
true,
|
||||
'usuario',
|
||||
'/melissa/documentos-templates',
|
||||
4,
|
||||
true,
|
||||
'[{"tipo": "imagem", "url": ""}]'::jsonb
|
||||
)
|
||||
RETURNING id INTO v_doc_id;
|
||||
|
||||
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
|
||||
(v_doc_id, 'Pra que serve a página de Templates?',
|
||||
$FAQ$Pra você gerenciar os <strong>modelos</strong> que serão usados na hora de gerar atestados, declarações, recibos, laudos e outros documentos clínicos. Cada template tem cabeçalho, corpo e rodapé com variáveis interpoladas (nome do paciente, CRP, data, etc) — quando você usa o botão <em>Gerar</em> num prontuário, é um desses templates que está sendo aplicado.$FAQ$, 0, true),
|
||||
|
||||
(v_doc_id, 'Por que não consigo editar os templates padrão (com badge "padrão")?',
|
||||
$FAQ$Templates marcados como <strong>globais</strong> (badge azul "padrão") vêm pré-instalados com o sistema e são compartilhados entre todos os tenants. Não dá pra editar pra preservar a versão de referência. Pra personalizar um, click em <strong>Duplicar</strong> — uma cópia vai pra <em>Seus templates</em> e ali você edita à vontade.$FAQ$, 1, true),
|
||||
|
||||
(v_doc_id, 'Como uso uma variável no template?',
|
||||
$FAQ$No editor (cabeçalho, corpo ou rodapé), posicione o cursor onde quer a variável e clique no botão de <strong>variáveis</strong> na barra de ferramentas. Um menu lista todas as variáveis disponíveis agrupadas por categoria. Click numa variável insere <code>{{nome_da_variavel}}</code> no cursor. Na hora de gerar o documento, esse placeholder é substituído pelo valor real.$FAQ$, 2, true),
|
||||
|
||||
(v_doc_id, 'Quais variáveis estão disponíveis?',
|
||||
$FAQ$Agrupadas por categoria — <strong>Paciente:</strong> nome, CPF, RG, data nascimento, email, telefone, endereço. <strong>Terapeuta:</strong> nome, email, telefone, registro profissional (formatado tipo "CRP 12345/SP"), tipo/número/UF do registro separados. <strong>Clínica:</strong> nome, endereço, telefone, CNPJ. <strong>Sessão:</strong> data, hora, valor, valor por extenso, forma de pagamento, modalidade. <strong>Geral:</strong> data atual, data atual por extenso. Lista completa visível no menu de variáveis do editor.$FAQ$, 3, true),
|
||||
|
||||
(v_doc_id, 'Posso recuperar um template que eu desativei?',
|
||||
$FAQ$Sim, mas hoje só via banco de dados (administrador). Desativar é <strong>soft-delete</strong>: o template ganha <code>ativo = false</code> e some da lista. Documentos antigos gerados com ele continuam acessíveis. Em versões futuras teremos um filtro "mostrar desativados" pra reativar via UI.$FAQ$, 4, true),
|
||||
|
||||
(v_doc_id, 'Como duplico um template padrão pra personalizar?',
|
||||
$FAQ$Click no card do template padrão pra abrir a <strong>Preview</strong>. No header da preview tem um botão <strong>Duplicar</strong>. Confirme — a cópia aparece em <em>Seus templates</em> com sufixo "(cópia)" no nome. Em seguida click em <strong>Editar</strong> nessa cópia pra ajustar texto, variáveis, cabeçalho, rodapé.$FAQ$, 5, true),
|
||||
|
||||
(v_doc_id, 'Qual a diferença prática entre template Global e do Tenant?',
|
||||
$FAQ$Globais são compartilhados entre todos os tenants (vêm com o sistema) e são <strong>read-only</strong>. Templates do tenant pertencem só à sua clínica/conta e são editáveis. Ambos aparecem juntos na hora de gerar um documento — você não precisa duplicar pra usar um global, só pra personalizar. Se um global atende, use direto.$FAQ$, 6, true),
|
||||
|
||||
(v_doc_id, 'Posso usar imagens no template (logo da clínica, assinatura digitalizada)?',
|
||||
$FAQ$Sim. O editor aceita inserção de imagens via toolbar. Recomendado: PNG ou JPG com tamanho moderado (logo até 200x80px, assinatura até 300x120px). Imagens muito grandes inflam o PDF gerado. Pra incluir o logo da clínica, prefira colocar no <strong>cabeçalho</strong> — assim aparece no topo de toda página do PDF.$FAQ$, 7, true),
|
||||
|
||||
(v_doc_id, 'O cabeçalho e rodapé aparecem em todas as páginas do PDF?',
|
||||
$FAQ$Sim. O renderizador usa CSS <code>@page</code> com cabeçalho fixo no topo e rodapé fixo no rodapé de cada página gerada. Documentos curtos (1 página) você não percebe; documentos longos (laudos extensos) repetem cabeçalho/rodapé automaticamente. Útil pra manter identificação da clínica em todas as folhas.$FAQ$, 8, true),
|
||||
|
||||
(v_doc_id, 'Como sei se um template tem variável obrigatória?',
|
||||
$FAQ$Hoje não há marcação "obrigatória" — todas as variáveis declaradas no template aparecem como editáveis na hora de gerar. Se uma vier vazia (porque não cadastrou no perfil/paciente/etc), o sistema mostra um hint embaixo do campo dizendo onde cadastrar (ex: <em>"Perfil → Registro Profissional"</em>). Você pode gerar mesmo com vazias — o placeholder fica como <code>{{variavel}}</code> no PDF, mas isso quase nunca é desejado.$FAQ$, 9, true),
|
||||
|
||||
(v_doc_id, 'Tem limite de templates por tenant?',
|
||||
$FAQ$Não há limite hard no banco. Em planos free pode haver limite por contrato (verifique seu plano em Configurações → Plano). Recomendado manter o conjunto enxuto (10-20 templates) pra não poluir o dropdown na hora de gerar — se você não usa, desative.$FAQ$, 10, true),
|
||||
|
||||
(v_doc_id, 'Os templates são compartilhados entre os terapeutas do mesmo tenant?',
|
||||
$FAQ$Sim. Todos os templates do tenant ficam disponíveis pra todos os usuários ativos do mesmo tenant (clínica). Quem cria/edita pode ser qualquer um com permissão de edição — não há "templates privados por usuário" no momento. Se precisar isolar templates por terapeuta, organize por nome (ex: "Atestado · Dra. Ana").$FAQ$, 11, true);
|
||||
|
||||
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
|
||||
END;
|
||||
$IMPORT$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,143 @@
|
||||
-- Importacao da doc Emissao de recibo profissional (Fase 4 #14)
|
||||
-- Gerado a partir de development/saas-docs/06-recibo-profissional-melissa.json
|
||||
BEGIN;
|
||||
|
||||
DO $IMPORT$
|
||||
DECLARE
|
||||
v_doc_id uuid;
|
||||
BEGIN
|
||||
INSERT INTO public.saas_docs (
|
||||
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
|
||||
pagina_path, ordem, ativo, medias
|
||||
) VALUES (
|
||||
'Emissão de recibo profissional',
|
||||
$HTML$<h2>Emissão de recibo profissional</h2>
|
||||
|
||||
<p>Quando uma sessão é registrada como <strong>paga</strong>, o sistema oferece um botão <em>Emitir recibo</em> que gera um PDF profissional pré-preenchido com todos os dados do paciente, terapeuta, clínica e da sessão — sem precisar passar pelo fluxo "Gerar a partir de template" manual.</p>
|
||||
|
||||
<h3>1. Quando o botão aparece</h3>
|
||||
<p>O botão <strong>Emitir recibo</strong> (outlined, ícone PDF) aparece no <em>painel financeiro do evento</em> (<code>AgendaEventoFinanceiroPanel</code>) — dentro do modal de uma sessão — somente quando:</p>
|
||||
<ul>
|
||||
<li>A sessão tem um <strong>financial_record vinculado</strong> (foi gerada cobrança via "Receber")</li>
|
||||
<li>O status do record é <strong><code>paid</code></strong> (pagamento já registrado)</li>
|
||||
</ul>
|
||||
<p>Em sessões de pacote (status='contrato'), sem cobrança gerada, pendente, ou cancelada — o botão não aparece. Use o fluxo manual de <em>Gerar</em> na aba Documentos pra emitir recibos de casos especiais.</p>
|
||||
|
||||
<h3>2. O que o recibo traz preenchido automaticamente</h3>
|
||||
<ul>
|
||||
<li><strong>Paciente:</strong> nome, CPF, RG (do cadastro do paciente)</li>
|
||||
<li><strong>Sessão:</strong> data e hora, modalidade</li>
|
||||
<li><strong>Valor:</strong> número (R$ 150,00) <strong>e por extenso</strong> ("cento e cinquenta reais")</li>
|
||||
<li><strong>Forma de pagamento:</strong> PIX, dinheiro, cartão, maquininha, etc — vindo do financial_record</li>
|
||||
<li><strong>Terapeuta:</strong> nome completo + registro profissional formatado ("CRP 12345/SP")</li>
|
||||
<li><strong>Clínica:</strong> nome, endereço, telefone, CNPJ formatado</li>
|
||||
<li><strong>Data atual:</strong> em formato curto (22/05/2026) e por extenso ("22 de maio de 2026")</li>
|
||||
</ul>
|
||||
|
||||
<h3>3. Registro profissional genérico</h3>
|
||||
<p>O sistema suporta <strong>qualquer conselho profissional</strong>, não só CRP. A formatação é automática a partir do que está cadastrado no <em>Perfil → Registro Profissional</em>:</p>
|
||||
<ul>
|
||||
<li><strong>CRP</strong> 12345/SP (psicologia)</li>
|
||||
<li><strong>CRM</strong> 67890/RJ (medicina)</li>
|
||||
<li><strong>CRFa</strong> 11111/MG (fonoaudiologia)</li>
|
||||
<li><strong>CREFITO</strong> 22222/SP (fisioterapia)</li>
|
||||
<li><strong>CRESS</strong> 33333/RS (serviço social)</li>
|
||||
<li><strong>CRN</strong> 44444/SP (nutrição)</li>
|
||||
<li>Ou personalizado via tipo "Outro" + nome livre</li>
|
||||
</ul>
|
||||
<p>No template, a variável <code>{{terapeuta_registro}}</code> sempre traz o registro formatado, independente do conselho. Tem também variáveis individuais: <code>{{terapeuta_registro_tipo}}</code>, <code>{{terapeuta_registro_numero}}</code>, <code>{{terapeuta_registro_uf}}</code> pra uso fino.</p>
|
||||
|
||||
<h3>4. Valor por extenso</h3>
|
||||
<p>Helper interno (<code>src/utils/valorExtenso.js</code>) converte número pra extenso em pt-BR completo até 999 milhões:</p>
|
||||
|
||||
<div style="background: rgba(99,102,241,0.06); border: 1px solid rgba(99,102,241,0.2); border-radius: 10px; padding: 12px 14px; margin: 12px 0; font-family: 'IBM Plex Mono', monospace; font-size: 0.82rem;">
|
||||
<strong>R$ 1,00</strong> → "um real"<br>
|
||||
<strong>R$ 150,00</strong> → "cento e cinquenta reais"<br>
|
||||
<strong>R$ 1.234,56</strong> → "mil duzentos e trinta e quatro reais e cinquenta e seis centavos"<br>
|
||||
<strong>R$ 0,50</strong> → "cinquenta centavos"<br>
|
||||
<strong>R$ 1.000.000,00</strong> → "um milhão de reais"
|
||||
</div>
|
||||
|
||||
<p>Pluralização correta (real/reais, centavo/centavos), tratamento de centavos isolados ("R$ 0,X"), milhar com "mil" sem "um", milhão/milhões.</p>
|
||||
|
||||
<h3>5. Onde o recibo é salvo</h3>
|
||||
<p>Ao clicar <strong>Emitir recibo</strong>:</p>
|
||||
<ol>
|
||||
<li>Sistema busca o template global <code>recibo_pagamento</code></li>
|
||||
<li>Carrega todas as variáveis (auto-fill descrito acima)</li>
|
||||
<li>Gera o PDF</li>
|
||||
<li>Faz upload pro bucket <code>generated-docs</code></li>
|
||||
<li>Insere registros em <code>documents</code> e <code>document_generated</code> (com linkage)</li>
|
||||
<li>Dispara <strong>download</strong> automático no browser</li>
|
||||
<li>Toast "Recibo emitido — PDF baixado e salvo nos documentos do paciente"</li>
|
||||
</ol>
|
||||
<p>O recibo aparece na aba <em>Documentos</em> do prontuário do paciente sob a categoria <strong>Recibo</strong>. Pode ser editado in-place, compartilhado ou assinado eletronicamente normalmente.</p>
|
||||
|
||||
<h3>6. Quick path vs flow manual</h3>
|
||||
<p>São <strong>2 caminhos</strong> pra gerar o mesmo PDF:</p>
|
||||
<ul>
|
||||
<li><strong>Quick path</strong> (este): clica num botão e pronto. Recibo da sessão paga, valor exato do record, forma de pagamento idem.</li>
|
||||
<li><strong>Flow manual</strong>: aba Documentos → Gerar → escolhe template "Recibo de Pagamento" → edita valores manualmente → preview → salva.</li>
|
||||
</ul>
|
||||
<p>Use o quick path no fluxo normal. Use o manual quando precisar emitir recibo de algo que não está vinculado a sessão (consulta avulsa) ou quando precisar ajustar valores.</p>
|
||||
|
||||
<h3>⚠️ Notas pro desenvolvedor</h3>
|
||||
<ul>
|
||||
<li><strong>Service:</strong> <code>emitirReciboParaSessao(eventoId, { patientId?, valor?, formaPagamento? })</code> em <code>DocumentGenerate.service.js</code>. Quick path one-call: busca template, carrega vars, gera, salva, download.</li>
|
||||
<li><strong>Helper extenso:</strong> <code>src/utils/valorExtenso.js</code> — pt-BR até 999 milhões. Atenção: zero retorna "zero reais", inputs inválidos retornam string vazia.</li>
|
||||
<li><strong>Mapeamento:</strong> <code>TEMPLATE_TYPE_TO_DOC_TYPE['recibo_pagamento'] = 'recibo'</code> garante que o doc gerado vai pra categoria certa na sidebar.</li>
|
||||
<li><strong>Template:</strong> migration <code>20260521000008_recibo_uses_terapeuta_registro.sql</code> trocou <code>"Psicólogo(a) - CRP {{terapeuta_crp}}"</code> por <code>{{terapeuta_registro}}</code> no template global. Universal pra qualquer conselho.</li>
|
||||
<li><strong>Botão UI:</strong> <code>AgendaEventoFinanceiroPanel.vue</code> linha ~320, branch <code>v-else-if="record.status === 'paid'"</code>.</li>
|
||||
</ul>$HTML$,
|
||||
'Financeiro',
|
||||
true,
|
||||
'usuario',
|
||||
'/melissa/agenda',
|
||||
6,
|
||||
true,
|
||||
'[{"tipo": "imagem", "url": ""}]'::jsonb
|
||||
)
|
||||
RETURNING id INTO v_doc_id;
|
||||
|
||||
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
|
||||
(v_doc_id, 'Como emito um recibo pra uma sessão que recebi o pagamento?',
|
||||
$FAQ$Abra a sessão no calendário da agenda → no painel <em>Cobrança</em> dentro do modal, com o pagamento já registrado (status <strong>Pago</strong>), aparece o botão <strong>Emitir recibo</strong>. Clique uma vez. O sistema gera o PDF, salva nos documentos do paciente e dispara o download automaticamente. Toast confirma a operação.$FAQ$, 0, true),
|
||||
|
||||
(v_doc_id, 'Por que o botão "Emitir recibo" não aparece na minha sessão?',
|
||||
$FAQ$O botão só aparece quando o financial_record da sessão tem <strong>status = pago</strong>. Possíveis motivos: (1) você não gerou cobrança ainda — clique em <em>Receber</em> pra registrar o pagamento primeiro; (2) cobrança está pendente — registre o recebimento; (3) sessão é de pacote (status='contrato') — pacotes não emitem recibo por sessão, use o fluxo manual em <em>Documentos → Gerar</em>; (4) cobrança foi cancelada — gere uma nova.$FAQ$, 1, true),
|
||||
|
||||
(v_doc_id, 'O valor por extenso vem certo ("cento e cinquenta reais")?',
|
||||
$FAQ$Sim, com gramática pt-BR correta até 999 milhões. Exemplos: R$ 1,00 → "um real", R$ 150,00 → "cento e cinquenta reais", R$ 1.234,56 → "mil duzentos e trinta e quatro reais e cinquenta e seis centavos", R$ 0,50 → "cinquenta centavos". Pluralização real/reais e centavo/centavos automática.$FAQ$, 2, true),
|
||||
|
||||
(v_doc_id, 'O recibo funciona pra qualquer conselho profissional (CRM, CRFa…)?',
|
||||
$FAQ$<strong>Sim.</strong> O template usa a variável <code>{{terapeuta_registro}}</code> que se adapta ao tipo de registro cadastrado no seu Perfil. Funciona pra CRP (psicologia), CRM (medicina), CRFa (fonoaudiologia), CREFITO (fisioterapia), CRESS (serviço social), CRN (nutrição), e qualquer outro conselho — incluindo "Outro" com nome livre. A formatação genérica fica tipo "CRP 12345/SP", "CRM 67890/RJ", etc.$FAQ$, 3, true),
|
||||
|
||||
(v_doc_id, 'Onde o recibo fica salvo depois de emitido?',
|
||||
$FAQ$Em <strong>2 lugares</strong>: (1) baixado automaticamente no seu computador via download do navegador; (2) salvo na aba <em>Documentos</em> do prontuário do paciente, na categoria <strong>Recibo</strong> da sidebar. Daí você pode reabrir, compartilhar com o paciente, enviar pra assinar, ou editar in-place se precisar ajustar.$FAQ$, 4, true),
|
||||
|
||||
(v_doc_id, 'Posso emitir recibo de algo que não é sessão (consulta avulsa, pacote)?',
|
||||
$FAQ$Sim, mas pelo <strong>fluxo manual</strong>: vá na aba <em>Documentos</em> do paciente → botão <strong>Gerar</strong> → escolha o template <em>"Recibo de Pagamento"</em>. Você preenche os valores na mão (valor, forma de pagamento, descrição) já que não vem de uma sessão específica. O resto (CRP, paciente, clínica) auto-completa igual.$FAQ$, 5, true),
|
||||
|
||||
(v_doc_id, 'Meu CRP/CRM aparece vazio no recibo, o que fazer?',
|
||||
$FAQ$Cadastre seu registro profissional em <strong>Perfil → Registro Profissional</strong>. Selecione o tipo (CRP/CRM/CRFa/…/Outro), número e UF. Salve. Próximos recibos gerados já trazem formatado. Pra atualizar recibos antigos, abra o doc na aba Documentos do paciente e use <em>Editar</em> — o sistema vai puxar o registro atualizado.$FAQ$, 6, true),
|
||||
|
||||
(v_doc_id, 'O CNPJ da clínica aparece formatado no recibo?',
|
||||
$FAQ$Sim, automaticamente. Em <strong>Configurações → Negócio (Tenant)</strong>, cadastre o CPF ou CNPJ no campo unificado. O sistema detecta pela quantidade de dígitos: 11 dígitos formata como CPF (XXX.XXX.XXX-XX), 14 como CNPJ (XX.XXX.XXX/XXXX-XX). O recibo usa a variável <code>{{clinica_cnpj}}</code> que sai formatada.$FAQ$, 7, true),
|
||||
|
||||
(v_doc_id, 'Errei o valor do recibo, posso corrigir sem gerar outro?',
|
||||
$FAQ$Sim. Vá na aba <em>Documentos</em> do paciente → abra o recibo no preview → clique em <strong>Editar</strong>. O dialog abre em modo edição com o template do recibo já carregado e os valores anteriores preenchidos. Ajuste o que precisa → <em>Substituir documento</em>. O PDF é regenerado e substitui o anterior, mantendo o mesmo ID e audit trail.$FAQ$, 8, true),
|
||||
|
||||
(v_doc_id, 'Posso enviar o recibo pro paciente assinar?',
|
||||
$FAQ$Sim. Recibos são documentos como qualquer outro — abra na aba Documentos → preview → botão <strong>Assinar</strong> na sidebar. Gera link público temporário, paciente abre sem login, marca aceite LGPD, assina. Útil pra recibos de valores altos ou contratos de pacote onde você quer registro formal da concordância.$FAQ$, 9, true),
|
||||
|
||||
(v_doc_id, 'Recibo de uma sessão antiga vai com a data de hoje ou a data da sessão?',
|
||||
$FAQ$<strong>As duas</strong>. O recibo traz a <em>data da sessão</em> ("Referente ao atendimento de 15/03/2026") e a <em>data atual de emissão</em> ("São Carlos, 22 de maio de 2026") no rodapé. Importante pra fiscal — a data de emissão indica quando o documento foi formalmente criado, mesmo que a sessão tenha sido meses atrás.$FAQ$, 10, true),
|
||||
|
||||
(v_doc_id, 'Posso reemitir um recibo que já foi emitido pra mesma sessão?',
|
||||
$FAQ$Sim, mas com cuidado. Clicar em <strong>Emitir recibo</strong> de novo gera um <strong>novo PDF</strong> e salva como novo documento na aba — você fica com 2 recibos da mesma sessão. Pra apenas atualizar (sem duplicar), edite o existente em <em>Documentos → preview → Editar</em>. Se duplicar por engano, exclua o antigo (soft-delete preserva por 5 anos no Lixo).$FAQ$, 11, true);
|
||||
|
||||
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
|
||||
END;
|
||||
$IMPORT$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,165 @@
|
||||
-- Importacao da doc Relatorios e exportacao (Fase 5 #13)
|
||||
-- Gerado a partir de development/saas-docs/07-relatorios-export-melissa.json
|
||||
BEGIN;
|
||||
|
||||
DO $IMPORT$
|
||||
DECLARE
|
||||
v_doc_id uuid;
|
||||
BEGIN
|
||||
INSERT INTO public.saas_docs (
|
||||
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
|
||||
pagina_path, ordem, ativo, medias
|
||||
) VALUES (
|
||||
'Relatórios de sessões e exportação',
|
||||
$HTML$<h2>Relatórios de sessões e exportação</h2>
|
||||
|
||||
<p>A página <strong>Relatórios</strong> (acessível em <code>/melissa/relatorios</code> ou Prontuários → Relatórios) consolida as sessões num período escolhido com KPIs, gráfico de evolução e tabela detalhada. Você pode <strong>exportar tudo pra PDF, Excel ou CSV</strong> respeitando os filtros aplicados.</p>
|
||||
|
||||
<h3>1. Layout 2-col</h3>
|
||||
<ul>
|
||||
<li><strong>Sidebar esquerda</strong> (~280px): cards de estatísticas clicáveis (atuam como filtros) + seletor de período + filtro por status</li>
|
||||
<li><strong>Main direita</strong>: gráfico de evolução (Chart.js) + DataTable de sessões filtradas com paginação</li>
|
||||
</ul>
|
||||
<p><strong>Mobile</strong> (<1024px): sidebar vira drawer acessado por botão "Filtros" no header.</p>
|
||||
|
||||
<h3>2. Filtros de período</h3>
|
||||
<p>4 opções no seletor:</p>
|
||||
<ul>
|
||||
<li><strong>Esta semana</strong> — domingo a sábado da semana atual</li>
|
||||
<li><strong>Este mês</strong> (default) — primeiro dia ao último dia do mês corrente</li>
|
||||
<li><strong>Últimos 3 meses</strong> — janela rolante de 3 meses até o fim do mês atual</li>
|
||||
<li><strong>Últimos 6 meses</strong> — idem, janela de 6 meses</li>
|
||||
</ul>
|
||||
<p>Ao trocar o período, dispara uma nova query no banco. Os KPIs, gráfico e tabela se atualizam.</p>
|
||||
|
||||
<h3>3. Estatísticas (KPIs)</h3>
|
||||
<p>Sidebar mostra cards com contadores do período:</p>
|
||||
<ul>
|
||||
<li><strong>Total de sessões</strong> — todas independente de status</li>
|
||||
<li><strong>Realizadas</strong> — concluídas com sucesso</li>
|
||||
<li><strong>Faltas</strong> — paciente faltou</li>
|
||||
<li><strong>Cancelamentos</strong> — sessão cancelada</li>
|
||||
<li><strong>Remarcadas</strong> — paciente remarcou</li>
|
||||
</ul>
|
||||
<p>Cada card é <strong>clicável</strong>: filtra a tabela mostrando apenas as sessões daquele status. Clique no mesmo card pra desfazer o filtro.</p>
|
||||
|
||||
<h3>4. Gráfico de evolução</h3>
|
||||
<p>Gráfico de barras/linhas (Chart.js) mostrando a evolução de sessões no período. O agrupamento adapta automaticamente:</p>
|
||||
<ul>
|
||||
<li><strong>Semana / Mês</strong> → agrupa por <strong>dia</strong></li>
|
||||
<li><strong>3 meses / 6 meses</strong> → agrupa por <strong>semana ISO</strong> ou <strong>mês ISO</strong></li>
|
||||
</ul>
|
||||
<p>Cores por status (verde = realizadas, vermelho = faltas, amarelo = canceladas, azul = remarcadas).</p>
|
||||
|
||||
<h3>5. Tabela detalhada</h3>
|
||||
<p>DataTable com colunas: data/hora, paciente, modalidade, status, valor (se aplicável), forma de pagamento. Paginada (15 por página default), ordenável por qualquer coluna. Status com tag colorida.</p>
|
||||
|
||||
<h3>6. Exportação — 3 formatos</h3>
|
||||
<p>3 botões no topo da tabela (ou header da página dependendo do layout):</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Botão</th><th>Formato</th><th>Quando usar</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><strong>📄 PDF</strong></td><td>PDF A4</td><td>Apresentar pra contador, anexar a processo, arquivo formal com identidade visual da clínica</td></tr>
|
||||
<tr><td><strong>📊 Excel</strong></td><td>XLSX</td><td>Análise no Excel/Google Sheets, fórmulas, gráficos próprios, manipulação fina</td></tr>
|
||||
<tr><td><strong>📋 CSV</strong></td><td>CSV UTF-8</td><td>Importar em outro sistema, processamento via script, BI externo</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style="background: rgba(34,197,94,0.06); border: 1px solid rgba(34,197,94,0.25); border-radius: 10px; padding: 12px 14px; margin: 14px 0; font-size: 0.85rem;">
|
||||
<strong>🎯 Os filtros aplicados na tela são respeitados</strong> — se você filtrou por "Realizadas" e exportou pra Excel, só as realizadas vão pro arquivo. Período idem. Quer todos os status? clique no card de filtro pra desfazer antes de exportar.
|
||||
</div>
|
||||
|
||||
<h3>7. Detalhes técnicos por formato</h3>
|
||||
|
||||
<h4>PDF</h4>
|
||||
<ul>
|
||||
<li>Renderizado client-side via HTML → PDF (mesmo pipeline do gerador de documentos)</li>
|
||||
<li>Cabeçalho com KPIs em destaque + tabela A4 abaixo</li>
|
||||
<li>Identidade visual: nome da clínica, logo (se cadastrado), data de geração</li>
|
||||
<li>Tamanho: 1 página por ~30 sessões; relatórios longos paginam automaticamente com cabeçalho/rodapé fixos</li>
|
||||
</ul>
|
||||
|
||||
<h4>Excel (XLSX)</h4>
|
||||
<ul>
|
||||
<li>Gerado com <code>exceljs</code> (import dinâmico — não infla o bundle inicial)</li>
|
||||
<li><strong>Frozen header</strong> — primeira linha fica fixa ao rolar</li>
|
||||
<li><strong>Alternating rows</strong> — zebrado pra leitura</li>
|
||||
<li>Colunas formatadas: data como data, valor como currency BRL</li>
|
||||
<li>Branded — cabeçalho com cor da clínica</li>
|
||||
</ul>
|
||||
|
||||
<h4>CSV</h4>
|
||||
<ul>
|
||||
<li>Vanilla JS — sem dependência externa, gerado instantaneamente</li>
|
||||
<li><strong>BOM UTF-8</strong> no início — força Excel a abrir com acentos corretos</li>
|
||||
<li><strong>Separador <code>;</code></strong> (padrão pt-BR — Excel BR espera ; em vez de ,)</li>
|
||||
<li>Aspas em campos com vírgula ou quebra de linha</li>
|
||||
</ul>
|
||||
|
||||
<h3>8. Nome do arquivo gerado</h3>
|
||||
<p>Padrão <code>relatorio_sessoes_AAAAMMDD_HHmm.{pdf|xlsx|csv}</code> com timestamp da hora de geração. Garante que múltiplas exportações no mesmo dia não sobrescrevem.</p>
|
||||
|
||||
<h3>⚠️ Notas pro desenvolvedor</h3>
|
||||
<ul>
|
||||
<li><strong>Service:</strong> <code>src/services/reportExport.service.js</code> com 3 funções: <code>exportSessionsToPDF</code>, <code>exportSessionsToXLSX</code>, <code>exportSessionsToCSV</code>. Todas aceitam <code>{ sessions, period, statusFilter, tenant }</code>.</li>
|
||||
<li><strong>PDF:</strong> usa <code>pdf.service.htmlToPdfBlob</code> (mesmo do gerador de documentos)</li>
|
||||
<li><strong>XLSX:</strong> <code>const { default: ExcelJS } = await import('exceljs')</code> — code splitting</li>
|
||||
<li><strong>CSV:</strong> vanilla JS com BOM + escape de campos</li>
|
||||
<li><strong>Pages:</strong> <code>RelatoriosPage.vue</code> (rota classic/Rail) e <code>MelissaRelatorios.vue</code> (rota Melissa) compartilham o mesmo service</li>
|
||||
<li><strong>Pendência:</strong> exportação agendada (envio automático por email no dia 1 de cada mês) — depende do Módulo 6 notifications. Hoje só on-demand.</li>
|
||||
</ul>$HTML$,
|
||||
'Relatórios',
|
||||
true,
|
||||
'usuario',
|
||||
'/melissa/relatorios',
|
||||
7,
|
||||
true,
|
||||
'[{"tipo": "imagem", "url": ""}]'::jsonb
|
||||
)
|
||||
RETURNING id INTO v_doc_id;
|
||||
|
||||
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
|
||||
(v_doc_id, 'Como vejo um resumo das minhas sessões num período?',
|
||||
$FAQ$Abra a página <strong>Relatórios</strong> (menu Prontuários → Relatórios, ou diretamente em <code>/melissa/relatorios</code>). Você escolhe o período na sidebar esquerda (esta semana, este mês, últimos 3 ou 6 meses) e o sistema mostra KPIs em cards, gráfico de evolução e tabela detalhada de cada sessão.$FAQ$, 0, true),
|
||||
|
||||
(v_doc_id, 'Quais períodos posso filtrar?',
|
||||
$FAQ$4 opções fixas no seletor: <strong>Esta semana</strong> (domingo a sábado da semana corrente), <strong>Este mês</strong> (default — dia 1 ao último dia do mês atual), <strong>Últimos 3 meses</strong> e <strong>Últimos 6 meses</strong> (janelas rolantes terminando no fim do mês atual). Não há custom date range na UI ainda — pra filtrar uma data específica, exporte pra Excel ou CSV e filtre lá.$FAQ$, 1, true),
|
||||
|
||||
(v_doc_id, 'Como exporto o relatório pra PDF?',
|
||||
$FAQ$No topo da página de Relatórios, clique no botão <strong>PDF</strong> (ícone vermelho de arquivo). O sistema renderiza o relatório com KPIs em destaque + tabela A4 e dispara o download. Útil pra apresentar pra contador, anexar a processos ou arquivar formalmente. O PDF traz a identidade visual da clínica (nome, logo se cadastrado, data de geração).$FAQ$, 2, true),
|
||||
|
||||
(v_doc_id, 'Como exporto pra Excel?',
|
||||
$FAQ$Botão <strong>Excel</strong> (ícone verde) no topo da página. Gera um arquivo <code>.xlsx</code> com cabeçalho fixo (frozen header), linhas zebradas pra leitura, colunas formatadas (datas como data, valores como moeda BRL) e cabeçalho com cor da clínica. Pronto pra análise no Excel, Google Sheets ou LibreOffice.$FAQ$, 3, true),
|
||||
|
||||
(v_doc_id, 'Quando devo usar CSV em vez de Excel?',
|
||||
$FAQ$Use <strong>CSV</strong> quando precisar importar os dados em outro sistema (ERP, BI, banco de dados), fazer processamento via script, ou compartilhar com alguém que não tenha Excel. O arquivo é mais leve e universal. Use <strong>Excel</strong> quando o destino final for análise humana — formatação de moeda, gráficos próprios, fórmulas. Os 2 trazem os mesmos dados.$FAQ$, 4, true),
|
||||
|
||||
(v_doc_id, 'Os filtros aplicados na tela também valem pra exportação?',
|
||||
$FAQ$<strong>Sim, sempre.</strong> Se você filtrou por "Realizadas" clicando no card de KPI, só as sessões realizadas vão pro arquivo exportado. Período idem. Quer exportar todos os status? clique no card de filtro pra desfazer (ou clique em outro KPI e depois nele de novo) antes de exportar. Na dúvida, o título do PDF/Excel sempre traz os filtros aplicados na primeira linha.$FAQ$, 5, true),
|
||||
|
||||
(v_doc_id, 'O Excel exportado tem fórmulas ou só dados?',
|
||||
$FAQ$Só dados. As colunas vêm formatadas (data como data, valor como moeda BRL) mas sem fórmulas pré-instaladas — você adiciona o que precisar depois (somas, médias, gráficos). Decisão de design: pra evitar conflito com diferentes locales/versões do Excel, exportamos puro e você customiza.$FAQ$, 6, true),
|
||||
|
||||
(v_doc_id, 'Por que o gráfico às vezes mostra dias e às vezes semanas/meses?',
|
||||
$FAQ$Agrupamento automático conforme o período pra evitar gráfico ilegível: <strong>Semana / Mês</strong> → 7-31 colunas por dia (legível). <strong>3 meses</strong> → ~13 colunas por semana ISO. <strong>6 meses</strong> → ~26 colunas ou ~6 colunas por mês ISO. Se forçássemos 180 colunas em "6 meses", ficaria ilegível.$FAQ$, 7, true),
|
||||
|
||||
(v_doc_id, 'Posso filtrar o relatório por um paciente específico?',
|
||||
$FAQ$Hoje não diretamente na página de Relatórios. Pra ver sessões de um paciente específico, vá no <strong>prontuário do paciente</strong> (aba Sessões) — lá tem timeline completa com filtros próprios. Ou exporte o relatório geral pra Excel/CSV e filtre por nome do paciente no Excel.$FAQ$, 8, true),
|
||||
|
||||
(v_doc_id, 'Consigo ver o relatório de outro terapeuta da clínica?',
|
||||
$FAQ$Depende da sua permissão no tenant. Por default, cada terapeuta vê só as próprias sessões. Owners/admins do tenant podem ter acesso aos relatórios consolidados de todos os profissionais — verifique em <strong>Configurações → Equipe</strong> qual é seu papel. Pra solicitar acesso ampliado, fale com o owner do tenant.$FAQ$, 9, true),
|
||||
|
||||
(v_doc_id, 'Como ficam os nomes dos arquivos exportados?',
|
||||
$FAQ$Padrão <code>relatorio_sessoes_AAAAMMDD_HHmm.{pdf|xlsx|csv}</code>. Exemplo: <code>relatorio_sessoes_20260522_1430.xlsx</code>. Timestamp garante que múltiplas exportações no mesmo dia não sobrescrevem o anterior — fica fácil organizar versões.$FAQ$, 10, true),
|
||||
|
||||
(v_doc_id, 'Posso agendar exportações automáticas (envio por email mensal)?',
|
||||
$FAQ$Ainda não. Hoje a exportação é <strong>on-demand</strong> — você precisa abrir a página e clicar no botão. Exportação agendada (ex: PDF mensal enviado por email no dia 1) está no roadmap pós-MVP, depende do Módulo 6 (notifications factory channel) que ainda não foi implementado. Por enquanto, agende um lembrete pra você abrir a página todo dia 1.$FAQ$, 11, true);
|
||||
|
||||
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
|
||||
END;
|
||||
$IMPORT$;
|
||||
|
||||
COMMIT;
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -15,7 +15,7 @@
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAjuda } from '@/composables/useAjuda';
|
||||
|
||||
@@ -73,6 +73,24 @@ function fechar() {
|
||||
faqAbertos.value = {};
|
||||
closeDrawer();
|
||||
}
|
||||
|
||||
// ── Fechar ao clicar fora ─────────────────────────────────────
|
||||
// Listener so existe enquanto o drawer esta aberto. Clique nos botoes
|
||||
// que abrem/fecham o drawer (marcados com data-ajuda-toggle) sao
|
||||
// ignorados — senao fecha aqui e o @click reabre.
|
||||
function onDocMouseDown(e) {
|
||||
if (!drawerOpen.value) return;
|
||||
const t = e.target;
|
||||
if (!(t instanceof Element)) return;
|
||||
if (t.closest('.ajuda-panel')) return; // dentro do drawer
|
||||
if (t.closest('[data-ajuda-toggle]')) return; // botao trigger
|
||||
closeDrawer();
|
||||
}
|
||||
watch(drawerOpen, (open) => {
|
||||
if (open) document.addEventListener('mousedown', onDocMouseDown, true);
|
||||
else document.removeEventListener('mousedown', onDocMouseDown, true);
|
||||
});
|
||||
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocMouseDown, true));
|
||||
// ── Highlight de elemento na página ──────────────────────────
|
||||
async function handleDocClick(e) {
|
||||
const anchor = e.target.closest('a[data-highlight]');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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): só "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>
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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): só um botao -->
|
||||
<Button v-if="isOnPatientsPage" label="Salvar" :loading="!!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
|
||||
<Button v-else-if="hideViewListButton" label="Salvar e fechar" :loading="!!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
|
||||
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver pacientes" -->
|
||||
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver paciente" -->
|
||||
<template v-else>
|
||||
<Button label="Salvar e fechar" severity="secondary" outlined :loading="pendingMode === 'only' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
|
||||
<Button label="Salvar e ver pacientes" :loading="pendingMode === 'view' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('view')" />
|
||||
<Button label="Salvar e ver paciente" :loading="pendingMode === 'view' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('view')" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -13,12 +13,19 @@ import { ref, watch, computed } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useDocumentGenerate } from '../composables/useDocumentGenerate'
|
||||
import { useDocumentTemplates } from '../composables/useDocumentTemplates'
|
||||
import { loadGeneratedFromDocId } from '@/services/DocumentGenerate.service'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
patientId: { type: String, default: null },
|
||||
patientName: { type: String, default: '' },
|
||||
agendaEventoId: { type: String, default: null }
|
||||
agendaEventoId: { type: String, default: null },
|
||||
// Modo edicao: ID de um documents.id existente. Quando setado, o dialog
|
||||
// busca o template_id + dados_preenchidos do document_generated vinculado,
|
||||
// pre-seleciona o template e popula as variaveis. Save vira UPDATE
|
||||
// in-place (preserva documents.id e audit). Doc sem registro generated
|
||||
// (uploaded direto) cai no flow normal de "select template".
|
||||
editingDocId: { type: String, default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'generated'])
|
||||
@@ -52,13 +59,48 @@ const {
|
||||
// ── Reset ao abrir ──────────────────────────────────────────
|
||||
|
||||
watch(() => props.visible, async (v) => {
|
||||
if (v) {
|
||||
step.value = 'select'
|
||||
reset()
|
||||
await Promise.all([
|
||||
fetchTemplates(),
|
||||
props.patientId ? loadVariables(props.patientId, props.agendaEventoId) : Promise.resolve()
|
||||
])
|
||||
if (!v) return;
|
||||
step.value = 'select'
|
||||
reset()
|
||||
await Promise.all([
|
||||
fetchTemplates(),
|
||||
props.patientId ? loadVariables(props.patientId, props.agendaEventoId) : Promise.resolve()
|
||||
])
|
||||
|
||||
// Modo edicao: tenta carregar o registro do generated, pre-seleciona
|
||||
// template e popula vars com dados_preenchidos (sobrescreve auto-vars
|
||||
// — preserva customizacao anterior do user). Se nao houver linkage
|
||||
// (doc uploaded direto), continua no flow normal de "select template".
|
||||
if (props.editingDocId) {
|
||||
const gen = await loadGeneratedFromDocId(props.editingDocId)
|
||||
if (gen?.template_id) {
|
||||
try {
|
||||
await selectTemplate(gen.template_id)
|
||||
// Merge: dados_preenchidos override auto-loaded variables.
|
||||
// Mantemos as vars que o user nao tinha customizado da vez
|
||||
// anterior (pra caso o template tenha vars novas adicionadas
|
||||
// depois) — pegamos as keys auto + sobrescreve com generated.
|
||||
const saved = gen.dados_preenchidos || {}
|
||||
Object.entries(saved).forEach(([k, val]) => {
|
||||
setVariable(k, val == null ? '' : String(val))
|
||||
})
|
||||
step.value = 'edit'
|
||||
} catch (e) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Template original não encontrado',
|
||||
detail: 'Selecione um template para regenerar o documento.',
|
||||
life: 3500
|
||||
})
|
||||
}
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Documento legado',
|
||||
detail: 'Sem dados de edição. Selecione um template para regenerar.',
|
||||
life: 3500
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -79,11 +121,19 @@ const editableVars = computed(() => {
|
||||
key,
|
||||
label: meta?.label || key,
|
||||
grupo: meta?.grupo || 'Outros',
|
||||
source: meta?.source || '',
|
||||
value: variables.value[key] || ''
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Estatística pro topo: quantos campos vieram do auto-fill vs vazios
|
||||
const varStats = computed(() => {
|
||||
const total = editableVars.value.length
|
||||
const filled = editableVars.value.filter(v => String(variables.value[v.key] || '').trim() !== '').length
|
||||
return { total, filled, empty: total - filled }
|
||||
})
|
||||
|
||||
const varGroups = computed(() => {
|
||||
const groups = {}
|
||||
for (const v of editableVars.value) {
|
||||
@@ -101,8 +151,15 @@ function onVarChange(key, val) {
|
||||
|
||||
async function onGenerate() {
|
||||
try {
|
||||
const result = await generateAndSave(props.patientId)
|
||||
toast.add({ severity: 'success', summary: 'Documento salvo', detail: 'Disponível nos documentos do paciente.', life: 3000 })
|
||||
const result = await generateAndSave(props.patientId, props.editingDocId || null)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: props.editingDocId ? 'Documento atualizado' : 'Documento salvo',
|
||||
detail: props.editingDocId
|
||||
? 'PDF substituído com os novos valores.'
|
||||
: 'Disponível nos documentos do paciente.',
|
||||
life: 3000
|
||||
})
|
||||
emit('generated', result)
|
||||
close()
|
||||
} catch (e) {
|
||||
@@ -145,10 +202,10 @@ function close() {
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center justify-center w-8 h-8 rounded-lg bg-green-500/10">
|
||||
<i class="pi pi-file-pdf text-green-600" />
|
||||
<i :class="editingDocId ? 'pi pi-pencil text-amber-600' : 'pi pi-file-pdf text-green-600'" />
|
||||
</span>
|
||||
<div>
|
||||
<div class="text-base font-semibold">Gerar documento</div>
|
||||
<div class="text-base font-semibold">{{ editingDocId ? 'Editar documento' : 'Gerar documento' }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">
|
||||
<template v-if="step === 'select'">Selecione um template</template>
|
||||
<template v-else-if="step === 'edit'">{{ selectedTemplate?.nome_template }} — {{ patientName }}</template>
|
||||
@@ -192,17 +249,54 @@ function close() {
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Editar variaveis -->
|
||||
<div v-else-if="step === 'edit'" class="flex flex-col gap-4">
|
||||
<div v-else-if="step === 'edit'" class="flex flex-col gap-5">
|
||||
<!-- Resumo do preenchimento automático -->
|
||||
<div
|
||||
v-if="varStats.total"
|
||||
class="flex items-center gap-2 text-xs px-3 py-2 rounded-lg"
|
||||
:class="varStats.empty === 0
|
||||
? 'bg-green-500/10 text-green-700 dark:text-green-400'
|
||||
: 'bg-amber-500/10 text-amber-700 dark:text-amber-400'"
|
||||
>
|
||||
<i :class="varStats.empty === 0 ? 'pi pi-check-circle' : 'pi pi-info-circle'" />
|
||||
<span v-if="varStats.empty === 0">
|
||||
Todos os {{ varStats.total }} campos foram preenchidos automaticamente.
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ varStats.filled }} de {{ varStats.total }} preenchidos. Os campos vazios mostram onde cadastrar o dado.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Erro de carregamento de variáveis -->
|
||||
<div
|
||||
v-if="genError"
|
||||
class="flex items-center gap-2 text-xs px-3 py-2 rounded-lg bg-red-500/10 text-red-600"
|
||||
>
|
||||
<i class="pi pi-exclamation-circle" />
|
||||
<span>{{ genError }}</span>
|
||||
</div>
|
||||
|
||||
<div v-for="(vars, grupo) in varGroups" :key="grupo">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-2">{{ grupo }}</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-3">{{ grupo }}</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div v-for="v in vars" :key="v.key" class="flex flex-col gap-1">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">{{ v.label }}</label>
|
||||
<InputText
|
||||
:modelValue="variables[v.key] || ''"
|
||||
@update:modelValue="onVarChange(v.key, $event)"
|
||||
class="w-full"
|
||||
/>
|
||||
<FloatLabel variant="on">
|
||||
<InputText
|
||||
:id="`docgen-var-${v.key}`"
|
||||
:modelValue="variables[v.key] || ''"
|
||||
@update:modelValue="onVarChange(v.key, $event)"
|
||||
class="w-full"
|
||||
:invalid="!String(variables[v.key] || '').trim()"
|
||||
/>
|
||||
<label :for="`docgen-var-${v.key}`">{{ v.label }}</label>
|
||||
</FloatLabel>
|
||||
<small
|
||||
v-if="!String(variables[v.key] || '').trim() && v.source"
|
||||
class="text-[0.65rem] text-[var(--text-color-secondary)] flex items-center gap-1 ml-1"
|
||||
>
|
||||
<i class="pi pi-link text-[0.55rem]" />
|
||||
{{ v.source }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -254,7 +348,7 @@ function close() {
|
||||
/>
|
||||
<Button
|
||||
v-if="step === 'preview'"
|
||||
label="Salvar documento"
|
||||
:label="editingDocId ? 'Substituir documento' : 'Salvar documento'"
|
||||
icon="pi pi-check"
|
||||
@click="onGenerate"
|
||||
:loading="generating"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useDocumentTemplates } from '../composables/useDocumentTemplates'
|
||||
import JoditEmailEditor from '@/components/ui/JoditEmailEditor.vue'
|
||||
|
||||
@@ -23,6 +23,8 @@ const emit = defineEmits(['update:modelValue', 'save', 'cancel'])
|
||||
const { TIPOS_TEMPLATE, TEMPLATE_VARIABLES, variablesGrouped, previewHtml } = useDocumentTemplates()
|
||||
|
||||
const activeTab = ref('editor') // editor | preview
|
||||
// Sub-tab do editor (centro do layout 3-col): qual seção renderiza
|
||||
const editorTab = ref('corpo') // cabecalho | corpo | rodape
|
||||
|
||||
// ── Form reativo synced com modelValue ──────────────────────
|
||||
|
||||
@@ -70,13 +72,43 @@ function insertVariable(varKey) {
|
||||
rodape_html: editorRodape
|
||||
}
|
||||
const editorRef = editorMap[cursorField.value]
|
||||
|
||||
// No mobile: fecha drawer + defere insertHTML pós-transição.
|
||||
// Restaura a selection capturada quando o drawer abriu (cursor
|
||||
// original do usuário) antes de inserir → variável aparece no
|
||||
// ponto certo do texto, não no final.
|
||||
if (isMobile.value) {
|
||||
drawerOpen.value = false;
|
||||
const markers = savedSelection.value;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (markers && editorRef?.value?.restoreSelection) {
|
||||
editorRef.value.restoreSelection(markers);
|
||||
}
|
||||
if (editorRef?.value?.insertHTML) {
|
||||
editorRef.value.insertHTML(tag);
|
||||
} else {
|
||||
// Fallback se a API expose falhar
|
||||
form.value[cursorField.value] = (form.value[cursorField.value] || '') + tag;
|
||||
}
|
||||
} catch {
|
||||
form.value[cursorField.value] = (form.value[cursorField.value] || '') + tag;
|
||||
}
|
||||
if (!form.value.variaveis.includes(varKey)) {
|
||||
form.value.variaveis = [...form.value.variaveis, varKey];
|
||||
}
|
||||
savedSelection.value = null;
|
||||
}, 280);
|
||||
return;
|
||||
}
|
||||
|
||||
// Desktop: insertHTML mantém posição do cursor (foco já tá no editor)
|
||||
if (editorRef?.value?.insertHTML) {
|
||||
editorRef.value.insertHTML(tag)
|
||||
} else {
|
||||
form.value[cursorField.value] = (form.value[cursorField.value] || '') + tag
|
||||
}
|
||||
|
||||
// Adiciona a variavel na lista se nao estiver
|
||||
if (!form.value.variaveis.includes(varKey)) {
|
||||
form.value.variaveis = [...form.value.variaveis, varKey]
|
||||
}
|
||||
@@ -87,129 +119,756 @@ function insertVariable(varKey) {
|
||||
function onSave() {
|
||||
emit('save', { ...form.value })
|
||||
}
|
||||
|
||||
// ── Mobile drawer (espelha padrão MelissaBloqueios/Templates) ─
|
||||
// No mobile, form (col 1) + variáveis (col 3) viram tabs dentro
|
||||
// de um drawer único. Só o editor (col 2) fica visível na tela.
|
||||
const drawerOpen = ref(false);
|
||||
const drawerTab = ref('form'); // form | vars
|
||||
const isMobile = ref(false);
|
||||
let _mqMobile = null;
|
||||
function _onMqMobileChange(e) {
|
||||
isMobile.value = e.matches;
|
||||
if (!e.matches) drawerOpen.value = false;
|
||||
}
|
||||
// Selection salva do editor ativo no momento de abrir o drawer de
|
||||
// variáveis. Permite inserir na posição original do cursor mesmo
|
||||
// depois do user navegar pelo drawer/perder foco.
|
||||
const savedSelection = ref(null);
|
||||
|
||||
function openDrawer(tab) {
|
||||
drawerTab.value = tab || 'form';
|
||||
// Quando abre "Variáveis", salva selection do editor ativo agora
|
||||
// (cursor original do usuário) pra restaurar depois da inserção.
|
||||
if (tab === 'vars') {
|
||||
const editorMap = {
|
||||
cabecalho_html: editorCabecalho,
|
||||
corpo_html: editorCorpo,
|
||||
rodape_html: editorRodape
|
||||
};
|
||||
const editorRef = editorMap[cursorField.value];
|
||||
savedSelection.value = editorRef?.value?.saveSelection?.() || null;
|
||||
} else {
|
||||
savedSelection.value = null;
|
||||
}
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
function fecharDrawer() {
|
||||
drawerOpen.value = false;
|
||||
savedSelection.value = null;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
_mqMobile = window.matchMedia('(max-width: 1023px)');
|
||||
isMobile.value = _mqMobile.matches;
|
||||
try { _mqMobile.addEventListener('change', _onMqMobileChange); }
|
||||
catch { _mqMobile.addListener(_onMqMobileChange); }
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (_mqMobile) {
|
||||
try { _mqMobile.removeEventListener('change', _onMqMobileChange); }
|
||||
catch { _mqMobile.removeListener(_onMqMobileChange); }
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ═══════ Page chrome (preenche o espaço do container pai) ═══════ */
|
||||
.dte-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
/* padding pra não grudar nas bordas do container pai (mdt-body) */
|
||||
padding: 12px;
|
||||
/* fallback pra quando o pai não é flex */
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dte-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dte-toolbar__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.dte-toolbar__title > i {
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.dte-toolbar__tabs {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ═══════ 3-col grid (form / editor / variáveis) ═══════ */
|
||||
.dte-cols {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 280px) minmax(0, 1fr) minmax(220px, 260px);
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* COL 1 — Form metadados */
|
||||
.dte-side {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dte-side__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dte-side__head > i {
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.dte-side__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.dte-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.dte-field label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* COL 2 — Editor com sub-tabs */
|
||||
.dte-main {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dte-main__tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dte-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 18px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: color 140ms ease, border-color 140ms ease, background-color 140ms ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
.dte-tab:hover {
|
||||
color: var(--text-color);
|
||||
background: color-mix(in srgb, var(--p-primary-color) 4%, transparent);
|
||||
}
|
||||
.dte-tab.is-active {
|
||||
color: var(--p-primary-color);
|
||||
border-bottom-color: var(--p-primary-color);
|
||||
background: color-mix(in srgb, var(--p-primary-color) 6%, transparent);
|
||||
}
|
||||
.dte-tab > i { font-size: 0.82rem; }
|
||||
|
||||
.dte-main__editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 14px;
|
||||
background: var(--surface-card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.dte-editor-wrap {
|
||||
flex: 1;
|
||||
min-height: 450px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.dte-editor-wrap > * {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Força o Jodit interno a expandir 100% da altura disponível
|
||||
(substitui o height: minHeight em pixels que o JoditEmailEditor seta) */
|
||||
.dte-editor-wrap :deep(.jodit-container) {
|
||||
flex: 1 !important;
|
||||
height: 100% !important;
|
||||
min-height: 450px !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
.dte-editor-wrap :deep(.jodit-workplace) {
|
||||
flex: 1 !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
.dte-editor-wrap :deep(.jodit-wysiwyg) {
|
||||
min-height: 100% !important;
|
||||
}
|
||||
|
||||
/* COL 3 — Variáveis */
|
||||
.dte-vars {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dte-vars__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 14px 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dte-vars__head > i {
|
||||
color: var(--p-primary-color);
|
||||
}
|
||||
.dte-vars__hint {
|
||||
margin: 0 14px 6px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-color-secondary);
|
||||
line-height: 1.4;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dte-vars__hint strong {
|
||||
color: var(--text-color);
|
||||
}
|
||||
.dte-vars__list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 4px 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.dte-vars__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.dte-vars__group-title {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.75;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.dte-vars__group-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.dte-vars__btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-color);
|
||||
font-size: 0.74rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
.dte-vars__btn:hover {
|
||||
background: color-mix(in srgb, var(--p-primary-color) 10%, transparent);
|
||||
color: var(--p-primary-color);
|
||||
}
|
||||
.dte-vars__btn-brace {
|
||||
font-family: 'JetBrains Mono', 'Consolas', monospace;
|
||||
font-size: 0.66rem;
|
||||
color: var(--p-primary-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.dte-vars__btn-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ═══════ Preview ═══════ */
|
||||
/* Container externo: scroll vertical interno + fundo sutil.
|
||||
NÃO usa flex (que limitava a altura intrínseca do doc) — usa
|
||||
block normal com o doc centralizado via margin auto. */
|
||||
.dte-preview {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
background: var(--surface-ground);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
}
|
||||
.dte-preview__doc {
|
||||
background: white;
|
||||
color: #1a1a1a;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
width: 100%;
|
||||
max-width: 794px; /* ≈ A4 a 96dpi */
|
||||
margin: 0 auto;
|
||||
padding: 48px 56px;
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
font-size: 12pt;
|
||||
line-height: 1.6;
|
||||
min-height: 500px;
|
||||
/* Garante que o background-white cresce com o conteúdo
|
||||
(em vez de ficar travado no min-height quando o doc é grande) */
|
||||
box-sizing: border-box;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
.dte-preview__header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
.dte-preview__body {
|
||||
min-height: 300px;
|
||||
}
|
||||
.dte-preview__footer {
|
||||
margin-top: 32px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #ccc;
|
||||
text-align: center;
|
||||
font-size: 10pt;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* ═══════ Toolbar mobile actions (botões "Identificação" / "Variáveis") ═══════ */
|
||||
.dte-toolbar__mobile-actions {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.dte-mobile-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
font-family: inherit;
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
.dte-mobile-btn:hover { background: color-mix(in srgb, var(--p-primary-color) 8%, transparent); }
|
||||
.dte-mobile-btn > i { color: var(--p-primary-color); font-size: 0.82rem; }
|
||||
|
||||
/* ═══════ Mobile drawer (form + variáveis em tabs) ═══════ */
|
||||
.dte-mobile-drawer {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
width: min(360px, 92vw);
|
||||
z-index: 80;
|
||||
background: var(--surface-card);
|
||||
border-right: 1px solid var(--surface-border);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: var(--text-color);
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.dte-mobile-drawer.is-open { transform: translateX(0); }
|
||||
/* Durante a transição de saída, drawer ignora eventos pra não capturar
|
||||
touch/click "perdidos" e prevenir trava no Jodit. */
|
||||
.dte-mobile-drawer:not(.is-open) { pointer-events: none; }
|
||||
|
||||
.dte-mobile-drawer__tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dte-drawer-tab {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: color 140ms ease, border-color 140ms ease, background-color 140ms ease;
|
||||
}
|
||||
.dte-drawer-tab:hover {
|
||||
color: var(--text-color);
|
||||
background: color-mix(in srgb, var(--p-primary-color) 4%, transparent);
|
||||
}
|
||||
.dte-drawer-tab.is-active {
|
||||
color: var(--p-primary-color);
|
||||
border-bottom-color: var(--p-primary-color);
|
||||
}
|
||||
.dte-drawer-tab > i { font-size: 0.82rem; }
|
||||
|
||||
.dte-drawer-close {
|
||||
width: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
border-left: 1px solid var(--surface-border);
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
.dte-drawer-close:hover {
|
||||
background: var(--surface-ground);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.dte-mobile-drawer__pane {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.dte-mobile-drawer__pane > .dte-side,
|
||||
.dte-mobile-drawer__pane > .dte-vars {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dte-mobile-drawer__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 79;
|
||||
}
|
||||
.dte-drawer-fade-enter-active,
|
||||
.dte-drawer-fade-leave-active { transition: opacity 200ms ease; }
|
||||
.dte-drawer-fade-enter-from,
|
||||
.dte-drawer-fade-leave-to { opacity: 0; }
|
||||
|
||||
/* ═══════ Mobile (<1024px): só o editor visível ═══════ */
|
||||
@media (max-width: 1023px) {
|
||||
/* Editor ocupa tela inteira — col 1 e col 3 viram drawer */
|
||||
.dte-cols {
|
||||
grid-template-columns: 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dte-cols > .dte-side,
|
||||
.dte-cols > .dte-vars { display: none; }
|
||||
|
||||
/* Mostra os botões "Identificação" / "Variáveis" no header */
|
||||
.dte-toolbar__mobile-actions { display: inline-flex; }
|
||||
|
||||
/* Esconde o título canônico no mobile (espaço pros botões) */
|
||||
.dte-toolbar__title > span { display: none; }
|
||||
.dte-toolbar__title > i { display: none; }
|
||||
|
||||
.dte-preview {
|
||||
padding: 12px;
|
||||
}
|
||||
.dte-preview__doc {
|
||||
padding: 24px 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 xl:gap-4">
|
||||
<!-- ══ Card: Identificação ══════════════════════════════ -->
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<i class="pi pi-tag text-[var(--text-color-secondary)] opacity-60" />
|
||||
<span class="font-semibold text-sm">Identificação</span>
|
||||
<!-- ══ Mobile drawer (form + variáveis em tabs) ════════════ -->
|
||||
<Transition name="dte-drawer-fade">
|
||||
<div
|
||||
v-show="isMobile && drawerOpen"
|
||||
class="dte-mobile-drawer"
|
||||
:class="{ 'is-open': drawerOpen }"
|
||||
>
|
||||
<div class="dte-mobile-drawer__tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="dte-drawer-tab"
|
||||
:class="{ 'is-active': drawerTab === 'form' }"
|
||||
@click="drawerTab = 'form'"
|
||||
>
|
||||
<i class="pi pi-tag" />
|
||||
<span>Identificação</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dte-drawer-tab"
|
||||
:class="{ 'is-active': drawerTab === 'vars' }"
|
||||
@click="drawerTab = 'vars'"
|
||||
>
|
||||
<i class="pi pi-code" />
|
||||
<span>Variáveis</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dte-drawer-close"
|
||||
v-tooltip.bottom="'Fechar'"
|
||||
@click="fecharDrawer"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-4 flex flex-col gap-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-[1fr_220px] gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Nome do template</label>
|
||||
<div id="dte-mobile-drawer-form" v-show="drawerTab === 'form'" class="dte-mobile-drawer__pane" />
|
||||
<div id="dte-mobile-drawer-vars" v-show="drawerTab === 'vars'" class="dte-mobile-drawer__pane" />
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition name="dte-drawer-fade">
|
||||
<div
|
||||
v-show="isMobile && drawerOpen"
|
||||
class="dte-mobile-drawer__backdrop"
|
||||
@click="fecharDrawer"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<div class="dte-page">
|
||||
<!-- ══ Toggle Editor / Preview no topo ══════════════════ -->
|
||||
<div class="dte-toolbar">
|
||||
<!-- Botões "Identificação" e "Variáveis" — mobile-only -->
|
||||
<div class="dte-toolbar__mobile-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="dte-mobile-btn"
|
||||
v-tooltip.bottom="'Identificação do template'"
|
||||
@click="openDrawer('form')"
|
||||
>
|
||||
<i class="pi pi-tag" />
|
||||
<span>Identificação</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dte-mobile-btn"
|
||||
v-tooltip.bottom="'Inserir variáveis'"
|
||||
@click="openDrawer('vars')"
|
||||
>
|
||||
<i class="pi pi-code" />
|
||||
<span>Variáveis</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dte-toolbar__title">
|
||||
<i class="pi pi-file-edit" />
|
||||
<span>Conteúdo do documento</span>
|
||||
</div>
|
||||
<div class="dte-toolbar__tabs">
|
||||
<Button
|
||||
label="Editor"
|
||||
icon="pi pi-pencil"
|
||||
:severity="activeTab === 'editor' ? undefined : 'secondary'"
|
||||
:outlined="activeTab !== 'editor'"
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
@click="activeTab = 'editor'"
|
||||
/>
|
||||
<Button
|
||||
label="Preview"
|
||||
icon="pi pi-eye"
|
||||
:severity="activeTab === 'preview' ? undefined : 'secondary'"
|
||||
:outlined="activeTab !== 'preview'"
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
@click="activeTab = 'preview'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ EDITOR — 3 colunas (form / editor / variáveis) ══ -->
|
||||
<div v-show="activeTab === 'editor'" class="dte-cols">
|
||||
<!-- ─── COL 1 (esquerda): Form de metadados — teleporta pro drawer no mobile ─── -->
|
||||
<Teleport to="#dte-mobile-drawer-form" :disabled="!isMobile">
|
||||
<aside class="dte-side">
|
||||
<div class="dte-side__head">
|
||||
<i class="pi pi-tag" />
|
||||
<span>Identificação</span>
|
||||
</div>
|
||||
<div class="dte-side__body">
|
||||
<div class="dte-field">
|
||||
<label>Nome do template</label>
|
||||
<InputText v-model="form.nome_template" placeholder="Ex: Declaração de comparecimento" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Tipo</label>
|
||||
<div class="dte-field">
|
||||
<label>Tipo</label>
|
||||
<Select v-model="form.tipo" :options="TIPOS_TEMPLATE" optionLabel="label" optionValue="value" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Descrição</label>
|
||||
<InputText v-model="form.descricao" placeholder="Breve descrição do template" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">URL do logo (opcional)</label>
|
||||
<InputText v-model="form.logo_url" placeholder="https://..." class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ Card: Conteúdo ═══════════════════════════════════ -->
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
<div class="flex items-center justify-between gap-2 px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<i class="pi pi-file-edit text-[var(--text-color-secondary)] opacity-60" />
|
||||
<span class="font-semibold text-sm">Conteúdo do documento</span>
|
||||
</div>
|
||||
<!-- Tabs: Editor / Preview -->
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
:label="'Editor'"
|
||||
icon="pi pi-pencil"
|
||||
:severity="activeTab === 'editor' ? undefined : 'secondary'"
|
||||
:outlined="activeTab !== 'editor'"
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
@click="activeTab = 'editor'"
|
||||
/>
|
||||
<Button
|
||||
:label="'Preview'"
|
||||
icon="pi pi-eye"
|
||||
:severity="activeTab === 'preview' ? undefined : 'secondary'"
|
||||
:outlined="activeTab !== 'preview'"
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
@click="activeTab = 'preview'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor -->
|
||||
<div v-show="activeTab === 'editor'" class="p-4 flex flex-col lg:flex-row gap-4">
|
||||
<!-- Campos HTML -->
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1" @focusin="cursorField = 'cabecalho_html'">
|
||||
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Cabeçalho</label>
|
||||
<JoditEmailEditor ref="editorCabecalho" v-model="form.cabecalho_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
|
||||
<div class="dte-field">
|
||||
<label>Descrição</label>
|
||||
<Textarea v-model="form.descricao" placeholder="Breve descrição do template" class="w-full" rows="3" autoResize />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1" @focusin="cursorField = 'corpo_html'">
|
||||
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Corpo do documento</label>
|
||||
<JoditEmailEditor ref="editorCorpo" v-model="form.corpo_html" :minHeight="350" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1" @focusin="cursorField = 'rodape_html'">
|
||||
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Rodapé</label>
|
||||
<JoditEmailEditor ref="editorRodape" v-model="form.rodape_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
|
||||
<div class="dte-field">
|
||||
<label>URL do logo (opcional)</label>
|
||||
<InputText v-model="form.logo_url" placeholder="https://..." class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</Teleport>
|
||||
|
||||
<!-- Painel de variáveis -->
|
||||
<div class="w-full lg:w-[240px] shrink-0">
|
||||
<div class="lg:sticky lg:top-3 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)]/50 overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<i class="pi pi-code text-[var(--text-color-secondary)] opacity-60 text-xs" />
|
||||
<span class="font-semibold text-xs">Variáveis</span>
|
||||
</div>
|
||||
<div class="px-3 pt-2 text-[0.68rem] text-[var(--text-color-secondary)] opacity-75 italic">Clique para inserir no campo ativo</div>
|
||||
<div class="flex flex-col gap-3 p-3 max-h-[500px] overflow-y-auto">
|
||||
<div v-for="(vars, grupo) in variablesGrouped" :key="grupo">
|
||||
<div class="text-[0.62rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-1">{{ grupo }}</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<button
|
||||
v-for="v in vars"
|
||||
:key="v.key"
|
||||
class="text-left text-xs px-2 py-1 rounded-md bg-transparent border-none hover:bg-[var(--primary-color,#6366f1)]/10 hover:text-[var(--primary-color,#6366f1)] transition-colors truncate cursor-pointer"
|
||||
:title="v.key"
|
||||
@click="insertVariable(v.key)"
|
||||
>
|
||||
<span class="font-mono text-[0.62rem] opacity-60">{{</span>
|
||||
{{ v.label }}
|
||||
<span class="font-mono text-[0.62rem] opacity-60">}}</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">{{</span>
|
||||
<span class="dte-vars__btn-label">{{ v.label }}</span>
|
||||
<span class="dte-vars__btn-brace">}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div v-show="activeTab === 'preview'" class="p-4">
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-white overflow-hidden">
|
||||
<div class="p-6 text-black" style="font-family: 'Segoe UI', Arial, sans-serif; font-size: 12pt; line-height: 1.6;">
|
||||
<div v-if="form.cabecalho_html" class="text-center mb-4 pb-3 border-b border-gray-300" v-html="renderedCabecalho" />
|
||||
<div class="min-h-[300px]" v-html="renderedPreview" />
|
||||
<div v-if="form.rodape_html" class="mt-8 pt-3 border-t border-gray-300 text-center text-[10pt] text-gray-500" v-html="renderedRodape" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</Teleport>
|
||||
</div>
|
||||
|
||||
<!-- ══ PREVIEW — full width ════════════════════════════ -->
|
||||
<div v-show="activeTab === 'preview'" class="dte-preview">
|
||||
<div class="dte-preview__doc">
|
||||
<div v-if="form.cabecalho_html" class="dte-preview__header" v-html="renderedCabecalho" />
|
||||
<div class="dte-preview__body" v-html="renderedPreview" />
|
||||
<div v-if="form.rodape_html" class="dte-preview__footer" v-html="renderedRodape" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -47,8 +47,15 @@ export function useDocumentGenerate() {
|
||||
error.value = null;
|
||||
try {
|
||||
variables.value = await loadAllVariables(patientId, agendaEventoId);
|
||||
// Hint útil pra diagnostico: se vier objeto mas todos campos vazios,
|
||||
// sinaliza que perfil/clínica/paciente provavelmente nao tem dados.
|
||||
const filled = Object.values(variables.value).filter(v => String(v ?? '').trim() !== '').length;
|
||||
if (filled === 0) {
|
||||
error.value = 'Nenhum dado foi encontrado pra auto-preencher. Verifique o cadastro do paciente, perfil e clínica.';
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar dados do paciente.';
|
||||
console.error('[useDocumentGenerate.loadVariables] falha:', e);
|
||||
error.value = e?.message || 'Erro ao carregar dados pra preenchimento.';
|
||||
variables.value = {};
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -92,9 +99,12 @@ export function useDocumentGenerate() {
|
||||
// ── Gerar PDF (client-side) ────────────────────────────
|
||||
|
||||
/**
|
||||
* Gera PDF blob, faz download, salva no Storage + banco.
|
||||
* Gera PDF blob, salva no Storage + banco.
|
||||
* @param {string} patientId
|
||||
* @param {string|null} editingDocId - se setado, UPDATE no doc existente
|
||||
* (in-place replace de PDF + metadados, preserva documents.id e audit).
|
||||
*/
|
||||
async function generateAndSave(patientId) {
|
||||
async function generateAndSave(patientId, editingDocId = null) {
|
||||
if (!selectedTemplate.value) throw new Error('Nenhum template selecionado.');
|
||||
|
||||
loading.value = true;
|
||||
@@ -112,7 +122,8 @@ export function useDocumentGenerate() {
|
||||
dadosPreenchidos: { ...variables.value },
|
||||
pdfBlob: blob,
|
||||
templateNome,
|
||||
templateTipo: selectedTemplate.value.tipo
|
||||
templateTipo: selectedTemplate.value.tipo,
|
||||
editingDocId
|
||||
});
|
||||
generatedDocs.value.unshift(result);
|
||||
return result;
|
||||
|
||||
@@ -620,8 +620,9 @@ onMounted(async () => {
|
||||
<NotificationDrawer />
|
||||
</div>
|
||||
|
||||
<!-- Ajuda -->
|
||||
<button type="button" class="rail-topbar__btn" :class="{ 'rail-topbar__btn--active': ajudaDrawerOpen }" :title="ajudaDrawerOpen ? 'Fechar ajuda' : 'Ajuda'" @click="toggleAjuda">
|
||||
<!-- Ajuda — data-ajuda-toggle ignora este botao no
|
||||
click-outside do AjudaDrawer (senao fecha + reabre). -->
|
||||
<button type="button" class="rail-topbar__btn" :class="{ 'rail-topbar__btn--active': ajudaDrawerOpen }" :title="ajudaDrawerOpen ? 'Fechar ajuda' : 'Ajuda'" data-ajuda-toggle @click="toggleAjuda">
|
||||
<i class="pi pi-question-circle" />
|
||||
</button>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -31,7 +31,6 @@ import ProximosFeriadosCard from '@/features/agenda/components/ProximosFeriadosC
|
||||
import MelissaAgendaHistoricoCard from './MelissaAgendaHistoricoCard.vue';
|
||||
import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue';
|
||||
import Popover from 'primevue/popover';
|
||||
import MelissaAgendaSearchPopover from './MelissaAgendaSearchPopover.vue';
|
||||
import MelissaAgendaActionsPopover from './MelissaAgendaActionsPopover.vue';
|
||||
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
|
||||
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
|
||||
@@ -691,29 +690,15 @@ const fcOptions = computed(() => ({
|
||||
}));
|
||||
|
||||
// ── Busca da toolbar (datas + paciente/título) ────────────────
|
||||
// Componente MelissaAgendaSearchPopover (autocontido) gerencia o Cmd+K
|
||||
// search inteiro — input, debounce, parsing de datas, resultados.
|
||||
// Aqui só ficam o ref pro toggle (botão da toolbar + hotkey global) e
|
||||
// os 2 handlers que decidem o que fazer com a escolha (gotoDate +
|
||||
// auto-select de paciente quando há patient_id no evento).
|
||||
const searchPopover = ref(null);
|
||||
|
||||
// Busca agora vive na MelissaBusca (global, Ctrl+K em qualquer tela ou
|
||||
// botao na .melissa-tray). Aqui fica so a fn gotoDate exposta pro
|
||||
// MelissaLayout chamar quando o usuario escolhe "Ir para [data]" na
|
||||
// busca global — vide defineExpose mais abaixo.
|
||||
function onBuscaGotoDate(date) {
|
||||
fcApi()?.gotoDate(date);
|
||||
refDate.value = new Date(date);
|
||||
}
|
||||
|
||||
function onBuscaSelectEvento(ev) {
|
||||
if (!ev?.inicio_em) return;
|
||||
fcApi()?.gotoDate(ev.inicio_em);
|
||||
refDate.value = new Date(ev.inicio_em);
|
||||
// Auto-seleciona o paciente se o evento tiver um — assim a agenda já
|
||||
// fica filtrada por ele e o dock contextual aparece.
|
||||
if (ev.patient_id) {
|
||||
pacienteSelecionadoId.value = ev.patient_id;
|
||||
}
|
||||
}
|
||||
|
||||
// Card de histórico (audit_logs) — ref pra disparar refetch após
|
||||
// mutações; handler que abre o evento clicado pelo id.
|
||||
const historicoCardRef = ref(null);
|
||||
@@ -760,18 +745,8 @@ async function onHistoricoOpen({ id }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Atalho global Ctrl/Cmd+K abre a busca via ref do popover.
|
||||
function _onSearchHotkey(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
||||
e.preventDefault();
|
||||
// Anchor virtual no botão da toolbar — necessário pra Popover do
|
||||
// PrimeVue posicionar corretamente.
|
||||
const btn = document.querySelector('.ma-cal__search-btn');
|
||||
if (btn) searchPopover.value?.toggle({ currentTarget: btn, target: btn });
|
||||
}
|
||||
}
|
||||
onMounted(() => { window.addEventListener('keydown', _onSearchHotkey); });
|
||||
onBeforeUnmount(() => { window.removeEventListener('keydown', _onSearchHotkey); });
|
||||
// Ctrl+K e' tratado pela propria MelissaBusca (listener global no
|
||||
// window) — removido o handler local pra nao disparar 2 vezes.
|
||||
|
||||
// Toolbar — atalhos pra FC API
|
||||
function fcApi() {
|
||||
@@ -1321,12 +1296,20 @@ function openProntuario(patient) {
|
||||
if (!patient?.id) return;
|
||||
abrirProntuarioPorId(patient.id);
|
||||
}
|
||||
// gotoDate exposto pro MelissaLayout chamar quando o usuario escolhe
|
||||
// "Ir para [data]" na MelissaBusca (busca global). Reusa onBuscaGotoDate
|
||||
// que ja atualiza fcApi + refDate.
|
||||
function gotoDateExternal(date) {
|
||||
onBuscaGotoDate(date);
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
refetch: refetchEventosFc,
|
||||
openProntuario,
|
||||
setView,
|
||||
openSessoesPaciente,
|
||||
openEditPatient
|
||||
openEditPatient,
|
||||
gotoDate: gotoDateExternal
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1629,21 +1612,10 @@ defineExpose({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Busca — sempre visível. Abre popover com input + lista de
|
||||
resultados. Suporta data (20/04, hoje) e texto (paciente/
|
||||
título). Ctrl/Cmd+K abre via hotkey global. -->
|
||||
<button
|
||||
class="ma-cal__icon ma-cal__search-btn w-7 h-7 grid place-items-center bg-transparent border-0 text-[var(--m-text)] rounded-md cursor-pointer text-[0.78rem] transition-colors duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] focus-visible:outline-2 focus-visible:outline-[var(--m-accent)] focus-visible:outline-offset-2"
|
||||
v-tooltip.top="'Buscar (Ctrl+K)'"
|
||||
@click="searchPopover?.toggle($event)"
|
||||
>
|
||||
<i class="pi pi-search" />
|
||||
</button>
|
||||
<MelissaAgendaSearchPopover
|
||||
ref="searchPopover"
|
||||
@goto-date="onBuscaGotoDate"
|
||||
@select-evento="onBuscaSelectEvento"
|
||||
/>
|
||||
<!-- Busca migrou pra .melissa-tray (sempre visivel).
|
||||
Ctrl+K em qualquer tela abre o mesmo spotlight,
|
||||
que ja entende data (20/04, hoje, amanha) e
|
||||
paciente/sessao via RPC search_global. -->
|
||||
|
||||
<!-- Bloquear: ícone-only com Menu popup. Visível só
|
||||
em ≥xl. Em <xl vai pra dentro de "Ações". -->
|
||||
@@ -2051,9 +2023,9 @@ defineExpose({
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* .ma-tsearch* migrou inteiro pra MelissaAgendaSearchPopover.vue (componente
|
||||
autocontido). Cmd+K hotkey global continua aqui no parent — chama
|
||||
`searchPopover.value?.toggle(...)` apontando pro botao .ma-cal__search-btn. */
|
||||
/* Busca da agenda migrou inteira pra MelissaBusca (componente global,
|
||||
no MelissaLayout). Acesso por Ctrl+K em qualquer tela ou pela lupa na
|
||||
.melissa-tray (canto inferior direito). Toolbar nao tem mais botao. */
|
||||
|
||||
/* .ma-actions* migrou inteiro pra MelissaAgendaActionsPopover.vue
|
||||
(componente autocontido, com utilities Tailwind no template). */
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
<script setup>
|
||||
/*
|
||||
* MelissaAgendaSearchPopover — busca da toolbar da Agenda Melissa
|
||||
* --------------------------------------------------------------
|
||||
* Pattern Cmd+K: input + lista de resultados num Popover. Suporta:
|
||||
* - Datas: "20/04", "20/04/2026", "hoje", "amanhã", "ontem"
|
||||
* → emit('goto-date', date) — pai navega o FullCalendar
|
||||
* - Texto livre: pesquisa server-side em patients.nome_completo + titulo
|
||||
* (via searchEventosByText) com debounce de 300ms. Limite 20 resultados.
|
||||
* → emit('select-evento', ev) — pai aciona gotoDate + auto-select patient
|
||||
*
|
||||
* Componente autocontido — owns todo o state, debounce, parsing.
|
||||
* Pai expõe um botão âncora (`.ma-cal__search-btn`) e chama
|
||||
* `popoverRef.value.toggle($event)` no click. Hotkey Cmd+K também
|
||||
* vive no pai (acha o botão via querySelector e chama toggle).
|
||||
*
|
||||
* Emit:
|
||||
* - goto-date(date: Date) — escolheu uma data do parser
|
||||
* - select-evento(ev) — escolheu um evento da lista de busca
|
||||
*
|
||||
* Exposto via defineExpose:
|
||||
* - toggle(event) — abre/fecha o popover, foca input quando abre
|
||||
*/
|
||||
import { ref, onBeforeUnmount } from 'vue';
|
||||
import Popover from 'primevue/popover';
|
||||
import { searchEventosByText } from './composables/useMelissaEventos';
|
||||
|
||||
const emit = defineEmits(['goto-date', 'select-evento']);
|
||||
|
||||
const popRef = ref(null);
|
||||
const inputRef = ref(null);
|
||||
const searchQuery = ref('');
|
||||
const searchResults = ref([]);
|
||||
const searchLoading = ref(false);
|
||||
const searchDateMatch = ref(null); // Date | null — preenchido se query parsear como data
|
||||
let _debounceTimer = null;
|
||||
// Token monotonico — protege contra race condition: se o user digita "ab"
|
||||
// e depois "abc", o request "ab" pode resolver DEPOIS do "abc" em conexao
|
||||
// lenta. Cada request guarda seu token; ao voltar, so aplica se ainda eh
|
||||
// o mais recente. Cancelamento via AbortController seria ideal mas exigiria
|
||||
// searchEventosByText aceitar signal — por ora token resolve sem mexer no API.
|
||||
let _searchToken = 0;
|
||||
|
||||
function parseSearchAsDate(str) {
|
||||
const t = String(str || '').trim().toLowerCase();
|
||||
if (!t) return null;
|
||||
if (t === 'hoje') { const d = new Date(); d.setHours(0,0,0,0); return d; }
|
||||
if (t === 'amanha' || t === 'amanhã') {
|
||||
const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() + 1); return d;
|
||||
}
|
||||
if (t === 'ontem') {
|
||||
const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() - 1); return d;
|
||||
}
|
||||
// DD/MM ou DD/MM/YYYY (também aceita - e .)
|
||||
const m = t.match(/^(\d{1,2})[/\-.](\d{1,2})(?:[/\-.](\d{2,4}))?$/);
|
||||
if (m) {
|
||||
const day = parseInt(m[1], 10);
|
||||
const month = parseInt(m[2], 10);
|
||||
let year = parseInt(m[3] || '', 10);
|
||||
if (!year || Number.isNaN(year)) year = new Date().getFullYear();
|
||||
if (year < 100) year += 2000;
|
||||
if (day < 1 || day > 31 || month < 1 || month > 12) return null;
|
||||
const d = new Date(year, month - 1, day);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toggle(event) {
|
||||
popRef.value?.toggle(event);
|
||||
// Foco no input quando abrir (Popover anima ~150ms). PrimeVue InputText
|
||||
// renderiza `<input>` direto, então `$el` já é o elemento focável.
|
||||
// Tentamos algumas vezes pra cobrir mount async + transição do Popover.
|
||||
let tries = 0;
|
||||
const tick = () => {
|
||||
const el = inputRef.value?.$el;
|
||||
if (el && typeof el.focus === 'function') { el.focus(); el.select?.(); return; }
|
||||
if (tries++ < 8) setTimeout(tick, 30);
|
||||
};
|
||||
setTimeout(tick, 80);
|
||||
}
|
||||
|
||||
function fechar() {
|
||||
try { popRef.value?.hide(); } catch {}
|
||||
}
|
||||
|
||||
// Toda vez que o popover fecha (via ESC, click-fora, ou submit/select),
|
||||
// reseta o state pra proxima abertura comecar limpa. Antes, ESC mantinha
|
||||
// query+resultados "fantasmas" — UX confusa: reabrir mostrava busca
|
||||
// antiga que talvez nao fizesse mais sentido.
|
||||
function onPopoverHide() {
|
||||
searchQuery.value = '';
|
||||
searchResults.value = [];
|
||||
searchDateMatch.value = null;
|
||||
searchLoading.value = false;
|
||||
++_searchToken; // invalida requests em flight
|
||||
}
|
||||
|
||||
function onSearchInput() {
|
||||
if (_debounceTimer) clearTimeout(_debounceTimer);
|
||||
const q = searchQuery.value;
|
||||
searchDateMatch.value = parseSearchAsDate(q);
|
||||
// Se digitou data, mostra resultado imediato (sem hit no DB)
|
||||
if (searchDateMatch.value) {
|
||||
// Invalida tokens em flight pra eles nao voltarem e sobrescreverem [].
|
||||
++_searchToken;
|
||||
searchResults.value = [];
|
||||
searchLoading.value = false;
|
||||
return;
|
||||
}
|
||||
if (String(q || '').trim().length < 2) {
|
||||
++_searchToken;
|
||||
searchResults.value = [];
|
||||
searchLoading.value = false;
|
||||
return;
|
||||
}
|
||||
searchLoading.value = true;
|
||||
_debounceTimer = setTimeout(async () => {
|
||||
const myToken = ++_searchToken;
|
||||
try {
|
||||
const results = await searchEventosByText(q);
|
||||
// Race guard: se outro request foi disparado depois deste,
|
||||
// descarta este (out-of-order resolution).
|
||||
if (myToken !== _searchToken) return;
|
||||
searchResults.value = results;
|
||||
} finally {
|
||||
// So zera loading se este eh o ultimo request — senao deixa o
|
||||
// proximo controlar o estado (evita flicker de loading=false
|
||||
// entre requests sequenciais rapidos).
|
||||
if (myToken === _searchToken) searchLoading.value = false;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function onSearchSubmit() {
|
||||
// Enter: prioriza data, senão pega o primeiro resultado
|
||||
if (searchDateMatch.value) {
|
||||
irParaData(searchDateMatch.value);
|
||||
return;
|
||||
}
|
||||
if (searchResults.value.length > 0) {
|
||||
selecionarResultado(searchResults.value[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function irParaData(date) {
|
||||
emit('goto-date', date);
|
||||
fechar(); // @hide handler limpa state
|
||||
}
|
||||
|
||||
function selecionarResultado(ev) {
|
||||
if (!ev?.inicio_em) return;
|
||||
emit('select-evento', ev);
|
||||
fechar(); // @hide handler limpa state
|
||||
}
|
||||
|
||||
function fmtDataResultado(iso) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', weekday: 'short' });
|
||||
}
|
||||
function fmtHoraResultado(iso) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (_debounceTimer) clearTimeout(_debounceTimer);
|
||||
});
|
||||
|
||||
defineExpose({ toggle, close: fechar });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover ref="popRef" class="ma-tsearch-pop" @hide="onPopoverHide">
|
||||
<div class="ma-tsearch flex flex-col w-[min(440px,calc(100vw-32px))] max-h-[500px] overflow-hidden">
|
||||
<div class="ma-tsearch__field relative flex items-center gap-2 px-2.5 py-1.5 mx-2 mt-2 mb-1.5 bg-[var(--m-bg-soft)] border border-[var(--m-border)] rounded-[10px] flex-shrink-0 transition-[border-color,background-color] duration-[140ms] focus-within:border-[var(--p-primary-color)] focus-within:bg-[var(--m-bg-soft-hover)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--p-primary-color)_12%,transparent)]">
|
||||
<i class="pi pi-search ma-tsearch__field-icon text-[var(--m-text-muted)] text-[0.85rem] flex-shrink-0" />
|
||||
<InputText
|
||||
ref="inputRef"
|
||||
v-model="searchQuery"
|
||||
placeholder="Data (20/04) ou nome do paciente…"
|
||||
class="ma-tsearch__input"
|
||||
@input="onSearchInput"
|
||||
@keydown.enter="onSearchSubmit"
|
||||
@keydown.esc="fechar"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
class="ma-tsearch__clear w-[22px] h-[22px] grid place-items-center border-0 bg-[var(--m-bg-medium)] text-[var(--m-text-muted)] rounded-full cursor-pointer flex-shrink-0 transition-colors duration-[140ms] hover:bg-[var(--m-border-strong)] hover:text-[var(--m-text)]"
|
||||
v-tooltip.top="'Limpar'"
|
||||
@click="searchQuery = ''; onSearchInput()"
|
||||
>
|
||||
<i class="pi pi-times text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Resultado data (sem hit no DB) -->
|
||||
<button
|
||||
v-if="searchDateMatch"
|
||||
class="ma-tsearch__result ma-tsearch__result--date w-auto self-stretch flex items-center gap-2.5 px-2.5 py-2 mx-2 mb-1.5 rounded-lg cursor-pointer text-left [font-family:inherit] transition-colors duration-[140ms]"
|
||||
@click="irParaData(searchDateMatch)"
|
||||
>
|
||||
<span class="ma-tsearch__result-icon w-8 h-8 grid place-items-center rounded-lg flex-shrink-0 text-[0.78rem]"><i class="pi pi-calendar" /></span>
|
||||
<span class="ma-tsearch__result-main flex-1 min-w-0 flex flex-col gap-0.5">
|
||||
<span class="ma-tsearch__result-title text-[0.85rem] font-medium whitespace-nowrap overflow-hidden text-ellipsis capitalize">Ir para {{ searchDateMatch.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', weekday: 'long' }) }}</span>
|
||||
<span class="ma-tsearch__result-sub text-[0.72rem] text-[var(--m-text-muted)] whitespace-nowrap overflow-hidden text-ellipsis">Pular para essa data no calendário</span>
|
||||
</span>
|
||||
<i class="pi pi-arrow-right text-xs opacity-50" />
|
||||
</button>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-else-if="searchLoading" class="ma-tsearch__loading flex items-center gap-2 px-3.5 py-[18px] text-[var(--m-text-muted)] text-[0.85rem]">
|
||||
<i class="pi pi-spin pi-spinner" /> <span>Buscando…</span>
|
||||
</div>
|
||||
|
||||
<!-- Resultados (eventos) -->
|
||||
<div v-else-if="searchResults.length > 0" class="ma-tsearch__results flex-1 min-h-0 overflow-y-auto p-1">
|
||||
<button
|
||||
v-for="ev in searchResults"
|
||||
:key="ev.id"
|
||||
class="ma-tsearch__result w-full flex items-center gap-2.5 px-2.5 py-2 border-0 bg-transparent text-[var(--m-text)] rounded-lg cursor-pointer text-left [font-family:inherit] transition-colors duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] focus-visible:bg-[var(--m-bg-soft-hover)] focus-visible:outline-none"
|
||||
@click="selecionarResultado(ev)"
|
||||
>
|
||||
<span class="ma-tsearch__result-icon w-8 h-8 grid place-items-center rounded-lg text-white flex-shrink-0 text-[0.78rem]" :style="{ background: ev.color }">
|
||||
<i :class="ev.tipo === 'sessao' ? 'pi pi-user' : 'pi pi-calendar'" />
|
||||
</span>
|
||||
<span class="ma-tsearch__result-main flex-1 min-w-0 flex flex-col gap-0.5">
|
||||
<span class="ma-tsearch__result-title text-[0.85rem] font-medium text-[var(--m-text)] whitespace-nowrap overflow-hidden text-ellipsis">{{ ev.label }}</span>
|
||||
<span class="ma-tsearch__result-sub text-[0.72rem] text-[var(--m-text-muted)] whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{{ fmtDataResultado(ev.inicio_em) }} · {{ fmtHoraResultado(ev.inicio_em) }}
|
||||
<span v-if="ev.modalidade"> · {{ ev.modalidade }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Vazio (já buscou mas nada encontrou) -->
|
||||
<div v-else-if="String(searchQuery || '').trim().length >= 2" class="ma-tsearch__empty flex flex-col items-center gap-2 px-4 py-[26px] mx-2 mb-2.5 mt-1 text-[var(--m-text-muted)] text-[0.82rem] border-[1.5px] border-dashed border-[color-mix(in_srgb,var(--p-primary-color)_22%,var(--m-border))] rounded-[14px] bg-[color-mix(in_srgb,var(--m-bg-soft)_50%,transparent)] text-center [&>i]:text-[2.2rem] [&>i]:opacity-70 [&>i]:text-[var(--p-primary-color)] [&>i]:mb-0.5">
|
||||
<i class="pi pi-search-minus" />
|
||||
<span class="ma-tsearch__empty-title text-[0.88rem] font-semibold text-[var(--m-text)]">Busca não encontrada…</span>
|
||||
<span class="ma-tsearch__empty-sub text-[0.78rem] text-[var(--m-text-muted)] leading-[1.35] [&_strong]:text-[var(--m-text)] [&_strong]:font-semibold">
|
||||
Nada para "<strong>{{ searchQuery }}</strong>"
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Hint inicial — Message PrimeVue (auto-resolve via PrimeVueResolver) -->
|
||||
<Message
|
||||
v-else
|
||||
severity="info"
|
||||
:closable="false"
|
||||
icon="pi pi-info-circle"
|
||||
class="ma-tsearch__hint mx-2 mb-2.5"
|
||||
>
|
||||
Digite uma data (<strong>20/04</strong>, <strong>hoje</strong>, <strong>amanhã</strong>) ou
|
||||
o nome do paciente (<strong>André</strong>).
|
||||
</Message>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Estilos que NAO migram pra utilities Tailwind:
|
||||
- .ma-tsearch__input.p-inputtext: override do PrimeVue InputText pra ele
|
||||
viver dentro do "field box" (zera bordas, bg, shadow padrao). Selector
|
||||
composto com classe externa do PrimeVue.
|
||||
- .ma-tsearch__hint :deep(.p-message-text): override de typography dentro
|
||||
do componente filho Message (isolado por scope).
|
||||
- .ma-tsearch__result--date: a versao "Ir para data" tem cores azul fixas
|
||||
(rgb diretas, sem var()) com !important pra blindar contra hover do
|
||||
base .ma-tsearch__result. State modifier mais limpo em CSS. */
|
||||
|
||||
.ma-tsearch__input.p-inputtext {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
padding: 8px 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--m-text);
|
||||
min-width: 0;
|
||||
}
|
||||
.ma-tsearch__input.p-inputtext:enabled:focus,
|
||||
.ma-tsearch__input.p-inputtext:enabled:hover {
|
||||
box-shadow: none;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
.ma-tsearch__input.p-inputtext::placeholder { color: var(--m-text-muted); }
|
||||
|
||||
.ma-tsearch__hint :deep(.p-message-text) {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ma-tsearch__hint strong { font-weight: 600; }
|
||||
|
||||
/* "Ir para data" — destaque azul claro independente da primary do tenant.
|
||||
Cores diretas (sem color-mix com bg-soft) pra garantir contraste em
|
||||
dark mode onde --m-bg-soft tem alpha 50% e diluiria o tint demais.
|
||||
!important pra blindar contra hover do .ma-tsearch__result base. */
|
||||
.ma-tsearch__result--date {
|
||||
background: rgba(59, 130, 246, 0.16) !important;
|
||||
border: 1.5px solid rgba(59, 130, 246, 0.55) !important;
|
||||
}
|
||||
.ma-tsearch__result--date:hover,
|
||||
.ma-tsearch__result--date:focus-visible {
|
||||
background: rgba(59, 130, 246, 0.26) !important;
|
||||
border-color: rgba(59, 130, 246, 0.75) !important;
|
||||
}
|
||||
.ma-tsearch__result--date .ma-tsearch__result-icon {
|
||||
background: #3b82f6 !important;
|
||||
color: white !important;
|
||||
}
|
||||
.ma-tsearch__result--date .ma-tsearch__result-title {
|
||||
color: #2563eb !important; /* blue-600 — boa leitura em ambos os modos */
|
||||
}
|
||||
/* Dark mode — clareia o título pra contrastar com o fundo escuro */
|
||||
html.app-dark .ma-tsearch__result--date .ma-tsearch__result-title {
|
||||
color: #93c5fd !important; /* blue-300 */
|
||||
}
|
||||
html.app-dark .ma-tsearch__result--date .ma-tsearch__result-sub {
|
||||
color: rgba(147, 197, 253, 0.7) !important;
|
||||
}
|
||||
</style>
|
||||
+325
-113
@@ -14,7 +14,7 @@
|
||||
* Quando promover pra produção: trocar a busca por chamada à RPC
|
||||
* `search_global` + manter a mesma estrutura de panel/items.
|
||||
*/
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useRecentPatients } from '@/composables/useRecentPatients';
|
||||
|
||||
@@ -33,7 +33,7 @@ const props = defineProps({
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['acao', 'paciente', 'evento', 'documento', 'intake']);
|
||||
const emit = defineEmits(['acao', 'paciente', 'evento', 'documento', 'intake', 'goto-date']);
|
||||
|
||||
const rootEl = ref(null);
|
||||
const inputEl = ref(null);
|
||||
@@ -62,12 +62,59 @@ function normalize(s) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Parser de data — portado de MelissaAgendaSearchPopover.
|
||||
// Aceita: "hoje", "amanha"/"amanhã", "ontem", "DD/MM", "DD/MM/YYYY"
|
||||
// (separadores /, - ou .). Retorna Date|null. Acao "Ir para esta data"
|
||||
// so se torna visivel quando ha match (vide dateMatch computed).
|
||||
function parseSearchAsDate(str) {
|
||||
const t = String(str || '').trim().toLowerCase();
|
||||
if (!t) return null;
|
||||
if (t === 'hoje') { const d = new Date(); d.setHours(0, 0, 0, 0); return d; }
|
||||
if (t === 'amanha' || t === 'amanhã') {
|
||||
const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + 1); return d;
|
||||
}
|
||||
if (t === 'ontem') {
|
||||
const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - 1); return d;
|
||||
}
|
||||
const m = t.match(/^(\d{1,2})[/\-.](\d{1,2})(?:[/\-.](\d{2,4}))?$/);
|
||||
if (m) {
|
||||
const day = parseInt(m[1], 10);
|
||||
const month = parseInt(m[2], 10);
|
||||
let year = parseInt(m[3] || '', 10);
|
||||
if (!year || Number.isNaN(year)) year = new Date().getFullYear();
|
||||
if (year < 100) year += 2000;
|
||||
if (day < 1 || day > 31 || month < 1 || month > 12) return null;
|
||||
const d = new Date(year, month - 1, day);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fmtHora(h) {
|
||||
const horas = Math.floor(h);
|
||||
const mins = Math.round((h - horas) * 60);
|
||||
return `${String(horas).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Match de data — se a query parseia como data, a primeira "linha" do
|
||||
// painel vira um card destacado "Ir para [data]" (igual ao popover da
|
||||
// agenda). Click/Enter dispara emit('goto-date', date) e o MelissaLayout
|
||||
// abre a agenda + navega o calendario.
|
||||
const dateMatch = computed(() => parseSearchAsDate(query.value));
|
||||
|
||||
function fmtDataLonga(d) {
|
||||
if (!(d instanceof Date) || Number.isNaN(d.getTime())) return '';
|
||||
// "Sábado, 20/06/2026" — primeira letra maiuscula no weekday
|
||||
const s = d.toLocaleDateString('pt-BR', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
const filteredAtalhos = computed(() => {
|
||||
const q = normalize(query.value);
|
||||
if (!q) return props.atalhos.slice(0, 4); // 4 defaults quando vazio
|
||||
@@ -79,6 +126,9 @@ const filteredAtalhos = computed(() => {
|
||||
|
||||
// Pacientes — combina RPC (autoritativo, todos os pacientes) com props (preview de hoje).
|
||||
// RPC tem prioridade; props complementa quando RPC ainda não trouxe nada.
|
||||
//
|
||||
// Shape do RPC search_global (patients): { id, label, sublabel, avatar_url, deeplink, score }
|
||||
// label = nome_completo; sublabel = email_principal ou telefone.
|
||||
const filteredPacientes = computed(() => {
|
||||
const q = normalize(query.value);
|
||||
if (q.length < 2) return [];
|
||||
@@ -87,15 +137,16 @@ const filteredPacientes = computed(() => {
|
||||
if (rpc.length) {
|
||||
return rpc.slice(0, 5).map(p => ({
|
||||
id: p.id,
|
||||
nome: p.nome_completo || p.nome_social || p.nome || '(sem nome)',
|
||||
email: p.email,
|
||||
telefone: p.telefone
|
||||
nome: p.label || '(sem nome)',
|
||||
sub: p.sublabel || '',
|
||||
avatar_url: p.avatar_url || null
|
||||
}));
|
||||
}
|
||||
// Fallback client-side
|
||||
// Fallback client-side (props.pacientes vem do MelissaLayout — shape diferente)
|
||||
return props.pacientes
|
||||
.filter((p) => normalize(p.nome).includes(q))
|
||||
.slice(0, 5);
|
||||
.slice(0, 5)
|
||||
.map(p => ({ id: p.id, nome: p.nome, sub: '', avatar_url: null }));
|
||||
});
|
||||
|
||||
const filteredEventos = computed(() => {
|
||||
@@ -118,6 +169,9 @@ const rpcIntakes = computed(() => rpcResults.value.intakes || []);
|
||||
|
||||
const flatList = computed(() => {
|
||||
const out = [];
|
||||
// "Ir para [data]" sempre no topo quando query parseia como data —
|
||||
// acao predominante (Enter direto seleciona ela).
|
||||
if (dateMatch.value) out.push({ group: 'goto-date', item: dateMatch.value, idx: 0 });
|
||||
recentItems.value.forEach((p, i) => out.push({ group: 'recent', item: p, idx: i }));
|
||||
filteredAtalhos.value.forEach((a, i) => out.push({ group: 'atalhos', item: a, idx: i }));
|
||||
filteredPacientes.value.forEach((p, i) => out.push({ group: 'pacientes', item: p, idx: i }));
|
||||
@@ -135,13 +189,22 @@ function findFlatIndex(group, idx) {
|
||||
}
|
||||
|
||||
function selectEntry(entry) {
|
||||
if (entry.group === 'atalhos') emit('acao', entry.item.id);
|
||||
if (entry.group === 'goto-date') emit('goto-date', entry.item);
|
||||
else if (entry.group === 'atalhos') emit('acao', entry.item.id);
|
||||
else if (entry.group === 'pacientes') emit('paciente', entry.item);
|
||||
else if (entry.group === 'recent') emit('paciente', { id: entry.item.id, nome: entry.item.nome, ...entry.item.extras });
|
||||
else if (entry.group === 'eventos') emit('evento', entry.item);
|
||||
else if (entry.group === 'rpc-appointments') emit('evento', entry.item);
|
||||
else if (entry.group === 'rpc-documents') emit('documento', entry.item);
|
||||
else if (entry.group === 'rpc-intakes') emit('intake', entry.item);
|
||||
else if (entry.group === 'rpc-appointments') {
|
||||
// Sessão da RPC: deeplink pra agenda com evento focado
|
||||
emit('evento', { id: entry.item.id, deeplink: entry.item.deeplink });
|
||||
} else if (entry.group === 'rpc-documents') {
|
||||
// Documento da RPC: extrai patient_id da deeplink se possível
|
||||
const dl = entry.item.deeplink || '';
|
||||
const m = dl.match(/patients\/([0-9a-f-]+)/i);
|
||||
emit('documento', { id: entry.item.id, patient_id: m?.[1] || null, label: entry.item.label });
|
||||
} else if (entry.group === 'rpc-intakes') {
|
||||
emit('intake', entry.item);
|
||||
}
|
||||
closePanel();
|
||||
}
|
||||
|
||||
@@ -149,44 +212,41 @@ function closePanel() {
|
||||
showPanel.value = false;
|
||||
query.value = '';
|
||||
activeIndex.value = -1;
|
||||
inputEl.value?.blur();
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
function openDialog() {
|
||||
showPanel.value = true;
|
||||
}
|
||||
|
||||
function onClickOutside(e) {
|
||||
if (rootEl.value && !rootEl.value.contains(e.target)) {
|
||||
showPanel.value = false;
|
||||
activeIndex.value = -1;
|
||||
}
|
||||
// Foca input do Dialog após ele montar
|
||||
nextTick(() => inputEl.value?.focus());
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (!showPanel.value) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
activeIndex.value = Math.min(activeIndex.value + 1, flatList.value.length - 1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
activeIndex.value = Math.max(activeIndex.value - 1, 0);
|
||||
} else if (e.key === 'Enter' && activeIndex.value >= 0) {
|
||||
e.preventDefault();
|
||||
selectEntry(flatList.value[activeIndex.value]);
|
||||
} else if (e.key === 'Escape') {
|
||||
// Stop bubbling pra ESC do parent não fechar overlay aleatório
|
||||
e.stopPropagation();
|
||||
closePanel();
|
||||
} else if (e.key === 'Enter') {
|
||||
// Enter sem selecao explicita: pega o primeiro item do flatList
|
||||
// (UX spotlight padrao — usuario digita "hoje" + Enter deve ir
|
||||
// direto pra hoje sem precisar ArrowDown).
|
||||
if (activeIndex.value >= 0) {
|
||||
e.preventDefault();
|
||||
selectEntry(flatList.value[activeIndex.value]);
|
||||
} else if (flatList.value.length > 0) {
|
||||
e.preventDefault();
|
||||
selectEntry(flatList.value[0]);
|
||||
}
|
||||
}
|
||||
// Escape é tratado pelo Dialog (dismissableMask + closable)
|
||||
}
|
||||
|
||||
function onGlobalKeydown(e) {
|
||||
// Ctrl+K / ⌘+K → foca input
|
||||
// Ctrl+K / ⌘+K → abre dialog
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
showPanel.value = true;
|
||||
inputEl.value?.focus();
|
||||
openDialog();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +265,14 @@ watch(query, (v) => {
|
||||
searching.value = false;
|
||||
return;
|
||||
}
|
||||
// Query parseou como data: pula RPC (nao faz sentido buscar paciente
|
||||
// chamado "20/06"). Card "Ir para data" cobre o caso sozinho.
|
||||
if (dateMatch.value) {
|
||||
++searchSeq; // invalida requests em flight
|
||||
resetRpcResults();
|
||||
searching.value = false;
|
||||
return;
|
||||
}
|
||||
searching.value = true;
|
||||
const mySeq = ++searchSeq;
|
||||
debounceT = setTimeout(async () => {
|
||||
@@ -234,34 +302,61 @@ watch(query, (v) => {
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', onClickOutside);
|
||||
window.addEventListener('keydown', onGlobalKeydown);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousedown', onClickOutside);
|
||||
window.removeEventListener('keydown', onGlobalKeydown);
|
||||
if (debounceT) clearTimeout(debounceT);
|
||||
});
|
||||
|
||||
// Exposto pro MelissaLayout — a lupa unica na .melissa-tray chama
|
||||
// melissaBuscaRef.openDialog() direto, e o provide('openMelissaBusca')
|
||||
// reusa o mesmo metodo pra qualquer descendente que queira abrir o
|
||||
// spotlight programaticamente. closeDialog alias do closePanel.
|
||||
defineExpose({ openDialog, closeDialog: closePanel });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootEl" class="mb-search">
|
||||
<div class="mb-field">
|
||||
<!-- Trigger: aparência de input, mas é botão (abre Dialog) -->
|
||||
<button type="button" class="mb-field" @click="openDialog" :aria-label="'Buscar (Ctrl+K)'">
|
||||
<i class="pi pi-search mb-field__icon" />
|
||||
<input
|
||||
ref="inputEl"
|
||||
v-model="query"
|
||||
type="text"
|
||||
placeholder="Buscar paciente, agenda, atalho…"
|
||||
class="mb-field__input"
|
||||
@focus="onFocus"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<span class="mb-field__placeholder">Buscar paciente, agenda, atalho…</span>
|
||||
<span class="mb-field__kbd" aria-hidden="true">Ctrl K</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Transition name="mb-fade">
|
||||
<div v-if="showPanel" class="mb-panel" role="listbox">
|
||||
<!-- Dialog Spotlight: input grande no topo + resultados em coluna -->
|
||||
<Dialog
|
||||
v-model:visible="showPanel"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="false"
|
||||
:dismissableMask="true"
|
||||
:showHeader="false"
|
||||
class="mb-dialog"
|
||||
:style="{ width: '640px', maxWidth: '94vw' }"
|
||||
pt:mask:class="mb-dialog__mask"
|
||||
pt:content:class="mb-dialog__content"
|
||||
@hide="closePanel"
|
||||
>
|
||||
<!-- Field do Dialog (input real, autofocus) -->
|
||||
<div class="mb-dialog__field">
|
||||
<i class="pi pi-search mb-dialog__field-icon" />
|
||||
<input
|
||||
ref="inputEl"
|
||||
v-model="query"
|
||||
type="text"
|
||||
placeholder="Buscar paciente, agenda, atalho…"
|
||||
class="mb-dialog__input"
|
||||
@keydown="onKeydown"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<span class="mb-dialog__esc" aria-hidden="true">Esc</span>
|
||||
</div>
|
||||
|
||||
<!-- Painel de resultados (scroll interno) -->
|
||||
<div class="mb-panel" role="listbox">
|
||||
<div
|
||||
v-if="query.trim().length >= 2 && !hasAnyResult"
|
||||
class="mb-empty"
|
||||
@@ -269,6 +364,25 @@ onBeforeUnmount(() => {
|
||||
Nada encontrado pra "<span class="text-white/80">{{ query.trim() }}</span>"
|
||||
</div>
|
||||
|
||||
<!-- "Ir para [data]" — quando query parseia como data
|
||||
(hoje/amanha/ontem/DD/MM/YYYY). Predominante: vai pra
|
||||
primeira linha do flatList e Enter direto seleciona. -->
|
||||
<div v-if="dateMatch" class="mb-group">
|
||||
<button
|
||||
class="mb-item mb-item--gotodate"
|
||||
:class="{ 'is-active': findFlatIndex('goto-date', 0) === activeIndex }"
|
||||
@click="selectEntry({ group: 'goto-date', item: dateMatch })"
|
||||
@mouseenter="activeIndex = findFlatIndex('goto-date', 0)"
|
||||
>
|
||||
<span class="mb-item__icon mb-item__icon--gotodate"><i class="pi pi-calendar" /></span>
|
||||
<span class="mb-item__main">
|
||||
<span class="mb-item__label">Ir para {{ fmtDataLonga(dateMatch) }}</span>
|
||||
<span class="mb-item__sub">Pular para essa data no calendário</span>
|
||||
</span>
|
||||
<i class="mb-item__go pi pi-arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Acessados recentemente (só quando query vazia) -->
|
||||
<div v-if="showRecent" class="mb-group">
|
||||
<div class="mb-group__title">Acessados recentemente</div>
|
||||
@@ -320,10 +434,10 @@ onBeforeUnmount(() => {
|
||||
@click="selectEntry({ group: 'pacientes', item: p })"
|
||||
@mouseenter="activeIndex = findFlatIndex('pacientes', i)"
|
||||
>
|
||||
<span class="mb-item__icon"><i class="pi pi-user" /></span>
|
||||
<span class="mb-item__icon mb-item__icon--patient"><i class="pi pi-user" /></span>
|
||||
<span class="mb-item__main">
|
||||
<span class="mb-item__label">{{ p.nome }}</span>
|
||||
<span class="mb-item__sub">Abrir prontuário</span>
|
||||
<span class="mb-item__sub">{{ p.sub || 'Abrir prontuário' }}</span>
|
||||
</span>
|
||||
<i class="mb-item__go pi pi-arrow-right" />
|
||||
</button>
|
||||
@@ -354,7 +468,10 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- RPC: Sessões/agendamentos (qualquer data) -->
|
||||
<!-- RPC: Sessões/agendamentos (qualquer data)
|
||||
RPC retorna { id, label, sublabel, deeplink }. Sublabel ja vem
|
||||
com "Paciente · dd/mm/yyyy HH:MM". Cor do icone = cor de sessao
|
||||
(indigo-500, igual ao pickColor() padrao). -->
|
||||
<div v-if="rpcAppointments.length" class="mb-group">
|
||||
<div class="mb-group__title">Sessões</div>
|
||||
<button
|
||||
@@ -365,10 +482,10 @@ onBeforeUnmount(() => {
|
||||
@click="selectEntry({ group: 'rpc-appointments', item: e })"
|
||||
@mouseenter="activeIndex = findFlatIndex('rpc-appointments', i)"
|
||||
>
|
||||
<span class="mb-item__icon"><i class="pi pi-calendar" /></span>
|
||||
<span class="mb-item__icon mb-item__icon--sessao"><i class="pi pi-calendar" /></span>
|
||||
<span class="mb-item__main">
|
||||
<span class="mb-item__label">{{ e.paciente_nome || e.title || 'Sessão' }}</span>
|
||||
<span class="mb-item__sub">{{ e.inicio_em ? new Date(e.inicio_em).toLocaleDateString('pt-BR') + ' ' + new Date(e.inicio_em).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : 'Sem data' }}</span>
|
||||
<span class="mb-item__label">{{ e.label || 'Sessão' }}</span>
|
||||
<span class="mb-item__sub">{{ e.sublabel || 'Sem detalhes' }}</span>
|
||||
</span>
|
||||
<i class="mb-item__go pi pi-arrow-right" />
|
||||
</button>
|
||||
@@ -385,10 +502,10 @@ onBeforeUnmount(() => {
|
||||
@click="selectEntry({ group: 'rpc-documents', item: d })"
|
||||
@mouseenter="activeIndex = findFlatIndex('rpc-documents', i)"
|
||||
>
|
||||
<span class="mb-item__icon"><i class="pi pi-file" /></span>
|
||||
<span class="mb-item__icon mb-item__icon--doc"><i class="pi pi-file" /></span>
|
||||
<span class="mb-item__main">
|
||||
<span class="mb-item__label">{{ d.nome_original || 'Documento' }}</span>
|
||||
<span class="mb-item__sub">{{ d.paciente_nome ? `${d.paciente_nome} · ` : '' }}{{ d.tipo_documento || 'outro' }}</span>
|
||||
<span class="mb-item__label">{{ d.label || 'Documento' }}</span>
|
||||
<span class="mb-item__sub">{{ d.sublabel || '' }}</span>
|
||||
</span>
|
||||
<i class="mb-item__go pi pi-arrow-right" />
|
||||
</button>
|
||||
@@ -405,16 +522,16 @@ onBeforeUnmount(() => {
|
||||
@click="selectEntry({ group: 'rpc-intakes', item: r })"
|
||||
@mouseenter="activeIndex = findFlatIndex('rpc-intakes', i)"
|
||||
>
|
||||
<span class="mb-item__icon"><i class="pi pi-inbox" /></span>
|
||||
<span class="mb-item__icon mb-item__icon--intake"><i class="pi pi-inbox" /></span>
|
||||
<span class="mb-item__main">
|
||||
<span class="mb-item__label">{{ r.nome_completo || 'Cadastro' }}</span>
|
||||
<span class="mb-item__sub">{{ r.created_at ? new Date(r.created_at).toLocaleDateString('pt-BR') : '' }}</span>
|
||||
<span class="mb-item__label">{{ r.label || 'Cadastro' }}</span>
|
||||
<span class="mb-item__sub">{{ r.sublabel || '' }}</span>
|
||||
</span>
|
||||
<i class="mb-item__go pi pi-arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -423,16 +540,17 @@ onBeforeUnmount(() => {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
/* Só horizontal — top/bottom ficam livres pro parent (mt-X do Tailwind) */
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
/* ─── Trigger no dock (visual de input, mas é botão que abre Dialog) ─── */
|
||||
.mb-field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
background: var(--m-bg-soft);
|
||||
backdrop-filter: blur(20px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(140%);
|
||||
@@ -440,11 +558,12 @@ onBeforeUnmount(() => {
|
||||
border-radius: 12px;
|
||||
padding: 0 14px;
|
||||
height: 44px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background-color 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
.mb-field:focus-within {
|
||||
.mb-field:hover {
|
||||
background: var(--m-bg-soft-hover);
|
||||
border-color: var(--m-border-strong);
|
||||
}
|
||||
.mb-field__icon {
|
||||
color: var(--m-text-muted);
|
||||
@@ -452,18 +571,15 @@ onBeforeUnmount(() => {
|
||||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mb-field__input {
|
||||
.mb-field__placeholder {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: white;
|
||||
color: var(--m-text-muted);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
min-width: 0;
|
||||
}
|
||||
.mb-field__input::placeholder {
|
||||
color: var(--m-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mb-field__kbd {
|
||||
color: var(--m-text-muted);
|
||||
@@ -478,49 +594,109 @@ onBeforeUnmount(() => {
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ─── Dialog Spotlight (PrimeVue Dialog customizado) ─── */
|
||||
:global(.mb-dialog__mask) {
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
background: rgba(0, 0, 0, 0.55) !important;
|
||||
}
|
||||
:global(.mb-dialog) {
|
||||
border-radius: 14px !important;
|
||||
overflow: hidden;
|
||||
/* Posiciona mais alto que o centro (estilo Spotlight) */
|
||||
margin-top: 10vh !important;
|
||||
align-self: flex-start;
|
||||
}
|
||||
:global(.mb-dialog .mb-dialog__content) {
|
||||
padding: 0 !important;
|
||||
background: var(--surface-card) !important;
|
||||
border-radius: 14px !important;
|
||||
overflow: hidden;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mb-dialog__field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mb-dialog__field-icon {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 1.05rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mb-dialog__input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text-color);
|
||||
font-size: 1.05rem;
|
||||
font-family: inherit;
|
||||
min-width: 0;
|
||||
}
|
||||
.mb-dialog__input::placeholder {
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.mb-dialog__esc {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--surface-ground);
|
||||
border: 1px solid var(--surface-border);
|
||||
flex-shrink: 0;
|
||||
letter-spacing: 0.05em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Panel agora vive DENTRO do Dialog (não mais absolute). Scroll interno
|
||||
resolve o bug de overflow — o conteúdo nunca passa do bottom porque
|
||||
o Dialog tem max-height e o panel é flex:1. */
|
||||
.mb-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
max-height: 60vh;
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
background: var(--m-bg-medium);
|
||||
backdrop-filter: blur(28px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||
border: 1px solid var(--m-border-strong);
|
||||
border-radius: 12px;
|
||||
overflow-x: hidden;
|
||||
padding: 6px;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
|
||||
background: var(--surface-card);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--m-border-strong) transparent;
|
||||
scrollbar-color: var(--surface-border) transparent;
|
||||
min-height: 0; /* permite shrink no flex */
|
||||
}
|
||||
.mb-panel::-webkit-scrollbar { width: 6px; }
|
||||
.mb-panel::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
background: var(--surface-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.mb-empty {
|
||||
padding: 18px 14px;
|
||||
text-align: center;
|
||||
color: var(--m-text-muted);
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.mb-group + .mb-group {
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--m-border);
|
||||
border-top: 1px solid var(--surface-border);
|
||||
}
|
||||
.mb-group__title {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--m-text-faint);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
padding: 8px 10px 4px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.mb-item {
|
||||
@@ -528,11 +704,11 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
padding: 9px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
color: var(--text-color);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
@@ -540,53 +716,89 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.mb-item:hover,
|
||||
.mb-item.is-active {
|
||||
background: var(--m-bg-soft);
|
||||
background: color-mix(in srgb, var(--p-primary-color) 8%, transparent);
|
||||
}
|
||||
.mb-item__icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--m-bg-soft);
|
||||
background: var(--surface-ground);
|
||||
border-radius: 7px;
|
||||
color: var(--m-text-muted);
|
||||
color: var(--text-color-secondary);
|
||||
flex-shrink: 0;
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
/* Cores por tipo — semântica fixa (não depende do tema, é categoria). */
|
||||
.mb-item__icon--patient {
|
||||
background: rgba(244, 114, 182, 0.18);
|
||||
color: #ec4899;
|
||||
}
|
||||
.mb-item__icon--sessao {
|
||||
background: rgba(99, 102, 241, 0.20);
|
||||
color: #6366f1;
|
||||
}
|
||||
.mb-item__icon--doc {
|
||||
background: rgba(14, 165, 233, 0.18);
|
||||
color: #0ea5e9;
|
||||
}
|
||||
.mb-item__icon--intake {
|
||||
background: rgba(251, 146, 60, 0.18);
|
||||
color: #f97316;
|
||||
}
|
||||
/* Dark mode: clareia as cores semânticas pra manter contraste */
|
||||
:root.app-dark .mb-item__icon--patient { color: #f9a8d4; }
|
||||
:root.app-dark .mb-item__icon--sessao { color: #a5b4fc; }
|
||||
:root.app-dark .mb-item__icon--doc { color: #7dd3fc; }
|
||||
:root.app-dark .mb-item__icon--intake { color: #fdba74; }
|
||||
|
||||
/* "Ir para [data]" — card azul predominante, mesmo padrao visual do
|
||||
popover da agenda (MelissaAgendaSearchPopover). Cores diretas (sem
|
||||
var/color-mix) pra garantir contraste em ambos os modos. */
|
||||
.mb-item--gotodate {
|
||||
background: rgba(59, 130, 246, 0.16);
|
||||
border: 1.5px solid rgba(59, 130, 246, 0.55);
|
||||
}
|
||||
.mb-item--gotodate:hover,
|
||||
.mb-item--gotodate.is-active {
|
||||
background: rgba(59, 130, 246, 0.26);
|
||||
border-color: rgba(59, 130, 246, 0.75);
|
||||
}
|
||||
.mb-item__icon--gotodate {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
.mb-item--gotodate .mb-item__label { color: #2563eb; font-weight: 600; }
|
||||
:root.app-dark .mb-item--gotodate .mb-item__label { color: #93c5fd; }
|
||||
:root.app-dark .mb-item--gotodate .mb-item__sub { color: rgba(147, 197, 253, 0.7); }
|
||||
|
||||
.mb-item__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
gap: 2px;
|
||||
}
|
||||
.mb-item__label {
|
||||
font-size: 0.85rem;
|
||||
color: white;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mb-item__sub {
|
||||
font-size: 0.7rem;
|
||||
color: var(--m-text-muted);
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-color-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mb-item__go {
|
||||
color: var(--m-text-faint);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.5;
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mb-fade-enter-active,
|
||||
.mb-fade-leave-active {
|
||||
transition: opacity 140ms ease, transform 160ms ease;
|
||||
}
|
||||
.mb-fade-enter-from,
|
||||
.mb-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* Cronômetro de sessão estilo "janela do Windows":
|
||||
* - Dialog centralizado com select de paciente, display gigante e ações
|
||||
* - Click fora minimiza (chip no canto superior esquerdo)
|
||||
* - X/ESC fecha (destrói)
|
||||
* - X = encerrar sem salvar. Com confirmacao se houver sessao em
|
||||
* andamento ou tempo decorrido (fechar limpo nao pede confirm)
|
||||
* - Botão inferior alterna entre "▶ Começar" e "⏹ Parar"
|
||||
* - "+1 minuto" estende o tempo
|
||||
* - Quando minimizado, o timer continua rodando em background
|
||||
@@ -23,8 +24,11 @@
|
||||
* cronoRef.value.fechar() // destrói
|
||||
*/
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { playToque } from './melissaToques';
|
||||
|
||||
const confirm = useConfirm();
|
||||
|
||||
const STORAGE_KEY = 'melissa.cronometro.v1';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -57,6 +61,16 @@ const seconds = ref(props.duracaoMinutos * 60);
|
||||
const pacienteId = ref(props.defaultPacienteId);
|
||||
let timer = null;
|
||||
|
||||
// Plano da sessao (horario programado original do evento na agenda).
|
||||
// Setado quando o cronometro abre a partir de um evento da timeline —
|
||||
// vide abrir({ sessionPlan: { startH, endH } }). Null quando aberto
|
||||
// manualmente. Persiste em localStorage junto com o resto do snap.
|
||||
const sessionPlan = ref(null); // { startH: number, endH: number } | null
|
||||
// Tick a cada 30s pra recomputar atraso conforme o tempo passa (so
|
||||
// quando cronometro existe; reseta no fechar).
|
||||
const _planNowTick = ref(Date.now());
|
||||
let _planNowTimer = null;
|
||||
|
||||
// True só durante a transição de minimizar (dialog → chip).
|
||||
// Permite trocar a animação leave do dialog por "shrink + fly to dock"
|
||||
// (tipo macOS minimize) em vez do lift normal usado pra fechar.
|
||||
@@ -90,21 +104,86 @@ const pacienteNome = computed(() => {
|
||||
return p ? p.nome : '';
|
||||
});
|
||||
|
||||
// Formatador hh:mm a partir do startH decimal (ex: 11.5 → "11:30").
|
||||
function _fmtHora(h) {
|
||||
if (typeof h !== 'number' || Number.isNaN(h)) return '';
|
||||
const hh = Math.floor(h);
|
||||
const mm = Math.round((h - hh) * 60);
|
||||
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const sessionPlanLabel = computed(() => {
|
||||
const p = sessionPlan.value;
|
||||
if (!p || typeof p.startH !== 'number' || typeof p.endH !== 'number') return '';
|
||||
return `Programado: ${_fmtHora(p.startH)} – ${_fmtHora(p.endH)}`;
|
||||
});
|
||||
|
||||
// Atraso em minutos vs horario programado. Calcula em relacao ao
|
||||
// _planNowTick (atualizado a cada 30s) pra nao recomputar a cada frame.
|
||||
const atrasoMin = computed(() => {
|
||||
const p = sessionPlan.value;
|
||||
if (!p || typeof p.startH !== 'number') return 0;
|
||||
// ref usada so pra forcar recompute periodico
|
||||
void _planNowTick.value;
|
||||
const d = new Date();
|
||||
const hNow = d.getHours() + d.getMinutes() / 60;
|
||||
const diff = hNow - p.startH;
|
||||
if (diff <= 0) return 0;
|
||||
return Math.round(diff * 60);
|
||||
});
|
||||
|
||||
// ── Watch: avisa parent quando dialog aparece/some ─────────────
|
||||
watch(visible, (v) => emit('visible-change', v));
|
||||
|
||||
// ── Ações ──────────────────────────────────────────────────────
|
||||
function abrir() {
|
||||
// abrir({ pacienteId, autostart }) — opts permitem pre-selecionar
|
||||
// um paciente (ex: click no botao "iniciar sessao" da timeline) e
|
||||
// auto-iniciar a contagem. Sem opts mantem comportamento legado.
|
||||
// Retorna { opened, alreadyRunning, pacienteId } pra caller decidir
|
||||
// (ex: mostrar toast se ja tem cronometro rodando de outro paciente).
|
||||
function abrir(opts = {}) {
|
||||
const requestedPid = Object.prototype.hasOwnProperty.call(opts, 'pacienteId')
|
||||
? opts.pacienteId
|
||||
: props.defaultPacienteId;
|
||||
if (exists.value) {
|
||||
// Já existe — apenas restaura se tava minimizado (não cria outro)
|
||||
// Ja existe — comportamento opcao (b): nao troca paciente. Apenas
|
||||
// restaura visualmente se estava minimizado. Caller decide se
|
||||
// mostra toast quando o paciente requisitado e' diferente do atual.
|
||||
if (minimized.value) minimized.value = false;
|
||||
return;
|
||||
return {
|
||||
opened: false,
|
||||
alreadyRunning: !!running.value,
|
||||
pacienteId: pacienteId.value,
|
||||
samePaciente: pacienteId.value === requestedPid
|
||||
};
|
||||
}
|
||||
seconds.value = props.duracaoMinutos * 60;
|
||||
pacienteId.value = props.defaultPacienteId;
|
||||
pacienteId.value = requestedPid;
|
||||
running.value = false;
|
||||
minimized.value = false;
|
||||
exists.value = true;
|
||||
// Plano programado (vem do evento da timeline) — usado pra exibir
|
||||
// "Programado: HH:MM – HH:MM" e badge de atraso. Sanitiza pra
|
||||
// garantir 2 numeros validos; senao limpa.
|
||||
const plan = opts.sessionPlan;
|
||||
if (plan && typeof plan.startH === 'number' && typeof plan.endH === 'number'
|
||||
&& !Number.isNaN(plan.startH) && !Number.isNaN(plan.endH)) {
|
||||
sessionPlan.value = { startH: plan.startH, endH: plan.endH };
|
||||
} else {
|
||||
sessionPlan.value = null;
|
||||
}
|
||||
if (opts.autostart) {
|
||||
// Defer pra rodar dps do mount/render do dialog (toggle precisa
|
||||
// do setInterval em proximo tick pra contar a partir do segundo
|
||||
// cheio, nao perde a fracao do tick atual).
|
||||
setTimeout(() => { if (exists.value && !running.value) toggle(); }, 0);
|
||||
}
|
||||
return {
|
||||
opened: true,
|
||||
alreadyRunning: false,
|
||||
pacienteId: pacienteId.value,
|
||||
samePaciente: true
|
||||
};
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
@@ -161,9 +240,33 @@ function fechar() {
|
||||
running.value = false;
|
||||
minimized.value = false;
|
||||
exists.value = false;
|
||||
sessionPlan.value = null; // limpa pra proxima abertura comecar zerada
|
||||
emit('close');
|
||||
}
|
||||
|
||||
// Fechar com confirmacao quando ha sessao em andamento ou tempo
|
||||
// decorrido sem salvar. Estado "clean" (parado + nada decorrido)
|
||||
// fecha direto pra nao atrapalhar quem abriu por engano.
|
||||
function confirmarFechar() {
|
||||
const totalInicial = props.duracaoMinutos * 60;
|
||||
const temAtividade = running.value || seconds.value !== totalInicial;
|
||||
if (!temAtividade) {
|
||||
fechar();
|
||||
return;
|
||||
}
|
||||
confirm.require({
|
||||
message: running.value
|
||||
? 'Sessão em andamento — encerrar agora descarta o tempo cronometrado sem salvar no DB.'
|
||||
: 'O cronômetro tem tempo decorrido que ainda não foi salvo. Quer descartar?',
|
||||
header: 'Encerrar sessão sem salvar?',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Encerrar sem salvar',
|
||||
rejectLabel: 'Continuar sessão',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: () => fechar()
|
||||
});
|
||||
}
|
||||
|
||||
function ajustarMinutos(delta) {
|
||||
seconds.value += delta * 60;
|
||||
// Re-baselina savedAt pra restore após reload pegar o novo valor correto
|
||||
@@ -185,7 +288,8 @@ function saveState() {
|
||||
minimized: !!minimized.value,
|
||||
running: !!running.value,
|
||||
seconds: seconds.value,
|
||||
savedAt: Date.now()
|
||||
savedAt: Date.now(),
|
||||
sessionPlan: sessionPlan.value // null | { startH, endH }
|
||||
};
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(snap)); } catch {}
|
||||
}
|
||||
@@ -223,6 +327,15 @@ function loadState() {
|
||||
seconds.value = restoredSeconds;
|
||||
exists.value = true;
|
||||
running.value = false; // toggle abaixo flipa pra true
|
||||
// Restaura plano programado se foi serializado. Sanitiza shape:
|
||||
// {startH:number, endH:number} valido ou null.
|
||||
const sp = snap.sessionPlan;
|
||||
if (sp && typeof sp.startH === 'number' && typeof sp.endH === 'number'
|
||||
&& !Number.isNaN(sp.startH) && !Number.isNaN(sp.endH)) {
|
||||
sessionPlan.value = { startH: sp.startH, endH: sp.endH };
|
||||
} else {
|
||||
sessionPlan.value = null;
|
||||
}
|
||||
|
||||
if (wasRunning) {
|
||||
// Retoma o interval. NÃO toca o toque retroativo — se o tempo
|
||||
@@ -235,6 +348,19 @@ function loadState() {
|
||||
// Watch nas mudanças de estado discreto (não em seconds enquanto roda — savedAt+delta dá conta)
|
||||
watch([exists, minimized, running, pacienteId], () => saveState());
|
||||
|
||||
// Tick a cada 30s pra recomputar atraso. So roda quando o cronometro
|
||||
// existe E tem plano programado — senao desperdicio.
|
||||
watch([exists, sessionPlan], ([e, p]) => {
|
||||
if (e && p) {
|
||||
if (!_planNowTimer) {
|
||||
_planNowTimer = setInterval(() => { _planNowTick.value = Date.now(); }, 30_000);
|
||||
}
|
||||
} else if (_planNowTimer) {
|
||||
clearInterval(_planNowTimer);
|
||||
_planNowTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Mount / Cleanup ────────────────────────────────────────────
|
||||
onMounted(() => {
|
||||
loadState();
|
||||
@@ -242,6 +368,7 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
if (_planNowTimer) clearInterval(_planNowTimer);
|
||||
});
|
||||
|
||||
defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible });
|
||||
@@ -264,7 +391,7 @@ defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible });
|
||||
<button class="mc-glass-btn" title="Minimizar" @click="minimizar">
|
||||
<i class="pi pi-window-minimize text-white/90 text-xs" />
|
||||
</button>
|
||||
<button class="mc-glass-btn" title="Fechar" @click="fechar">
|
||||
<button class="mc-glass-btn" title="Encerrar sem salvar" @click="confirmarFechar">
|
||||
<i class="pi pi-times text-white/90 text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -284,6 +411,17 @@ defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible });
|
||||
</select>
|
||||
<i class="pi pi-chevron-down mc-select-icon" />
|
||||
</div>
|
||||
<!-- Plano programado da sessao (so quando aberto via
|
||||
evento da timeline). Mostra horario original +
|
||||
badge de atraso se aplicavel — analista decide,
|
||||
cronometro nao auto-ajusta. -->
|
||||
<div v-if="sessionPlanLabel" class="mc-session-plan">
|
||||
<i class="pi pi-calendar text-white/55 text-[0.7rem]" />
|
||||
<span class="text-white/70 text-[0.78rem]">{{ sessionPlanLabel }}</span>
|
||||
<span v-if="atrasoMin > 0" class="mc-session-plan__late">
|
||||
atrasada {{ atrasoMin }} min
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Display gigante + steppers manuais (+5 / -5) -->
|
||||
@@ -436,6 +574,28 @@ defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible });
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Plano programado — linha abaixo do select com horario original e
|
||||
badge de atraso quando aplicavel. Tom secundario pra nao roubar
|
||||
atencao do display gigante do cronometro. */
|
||||
.mc-session-plan {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.mc-session-plan__late {
|
||||
margin-left: 4px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(251, 146, 60, 0.18);
|
||||
color: rgb(253, 186, 116);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
border: 1px solid rgba(251, 146, 60, 0.35);
|
||||
}
|
||||
|
||||
/* ─── Display gigante ──────────────────────────────────────── */
|
||||
.mc-display {
|
||||
font-size: 5rem;
|
||||
@@ -567,6 +727,14 @@ defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible });
|
||||
padding-left: 6px;
|
||||
border-left: 1px solid var(--m-border-strong);
|
||||
}
|
||||
/* Em mobile (<md=768px) o chip vive num dock estreito que precisa
|
||||
acomodar 4 builtins + ψ + tray. Esconde o nome do paciente — o
|
||||
icone + timer ja sinalizam o estado, e o nome continua disponivel
|
||||
ao restaurar o cronometro (click). */
|
||||
@media (max-width: 767px) {
|
||||
.mc-chip-name { display: none; }
|
||||
.mc-chip { padding: 8px 12px; }
|
||||
}
|
||||
.mc-chip-pulse {
|
||||
animation: mc-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
< {{ tpl.variaveis?.length || 0 }} variáveis >
|
||||
</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; }
|
||||
|
||||
@@ -72,7 +72,7 @@ const emit = defineEmits(['cronometro', 'toggle-filtro']);
|
||||
class="resumo-link"
|
||||
:class="{ 'is-active': filtroTipo === p.tipo }"
|
||||
@click="emit('toggle-filtro', p.tipo)"
|
||||
>{{ p.text }}</button><span v-if="i < resumoPartes.length - 2">, </span><span v-else-if="i === resumoPartes.length - 2"> e </span>
|
||||
>{{ p.text }}</button><span v-if="p.suffix" class="resumo-suffix">{{ p.suffix }}</span><span v-if="i < resumoPartes.length - 2">, </span><span v-else-if="i === resumoPartes.length - 2"> e </span>
|
||||
</template>.
|
||||
</template>
|
||||
</span>
|
||||
@@ -135,6 +135,13 @@ const emit = defineEmits(['cronometro', 'toggle-filtro']);
|
||||
border-bottom: 1px solid var(--m-text);
|
||||
}
|
||||
|
||||
/* Sufixo "(1 foi cancelado, 2 foram remarcados)" — texto secundario,
|
||||
nao clicavel, com peso menor pra nao competir com o chip. */
|
||||
.resumo-suffix {
|
||||
color: var(--m-text-muted, rgba(255, 255, 255, 0.6));
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* ─── Modo "fundo nos textos soltos" ──────────────────────────
|
||||
Toggle no painel Personalizar. Cada bloco de texto (.hero-text)
|
||||
ganha um fundo solido translucido + borda + padding pra ficar
|
||||
|
||||
@@ -575,9 +575,50 @@ function setPreset(name) {
|
||||
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Settings popover (canto superior direito)
|
||||
// Settings popover (canto inferior direito — vive na .melissa-tray)
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
const settingsOpen = ref(false);
|
||||
const cogBtnEl = ref(null);
|
||||
|
||||
// "More" tray popup — visivel so em mobile (<md). Collapse de bell/
|
||||
// help/cog/plan-DEV num menu vertical pra economizar largura.
|
||||
const trayMoreOpen = ref(false);
|
||||
const trayMoreBtnEl = ref(null);
|
||||
|
||||
// Fechar ao clicar fora: listener so existe enquanto o popover esta
|
||||
// aberto. mousedown (capture) fecha antes do click chegar — mas o
|
||||
// proprio cog precisa ser ignorado, senao fecha aqui e o @click do
|
||||
// botao re-abre na sequencia.
|
||||
function onSettingsDocMouseDown(e) {
|
||||
if (!settingsOpen.value) return;
|
||||
const t = e.target;
|
||||
if (!(t instanceof Element)) return;
|
||||
if (t.closest('.mp-panel')) return; // clique dentro do panel
|
||||
if (cogBtnEl.value?.contains(t)) return; // clique no proprio cog
|
||||
settingsOpen.value = false;
|
||||
}
|
||||
watch(settingsOpen, (open) => {
|
||||
if (open) document.addEventListener('mousedown', onSettingsDocMouseDown, true);
|
||||
else document.removeEventListener('mousedown', onSettingsDocMouseDown, true);
|
||||
});
|
||||
onBeforeUnmount(() => document.removeEventListener('mousedown', onSettingsDocMouseDown, true));
|
||||
|
||||
// Mesmo padrao pro popup "More" do tray em mobile: ignora o proprio
|
||||
// botao trigger (senao fecha + reabre no click) e fecha em qualquer
|
||||
// outro lugar fora do panel.
|
||||
function onTrayMoreDocMouseDown(e) {
|
||||
if (!trayMoreOpen.value) return;
|
||||
const t = e.target;
|
||||
if (!(t instanceof Element)) return;
|
||||
if (t.closest('.melissa-tray__more-panel')) return;
|
||||
if (trayMoreBtnEl.value?.contains(t)) return;
|
||||
trayMoreOpen.value = false;
|
||||
}
|
||||
watch(trayMoreOpen, (open) => {
|
||||
if (open) document.addEventListener('mousedown', onTrayMoreDocMouseDown, true);
|
||||
else document.removeEventListener('mousedown', onTrayMoreDocMouseDown, true);
|
||||
});
|
||||
onBeforeUnmount(() => document.removeEventListener('mousedown', onTrayMoreDocMouseDown, true));
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Timeline horizontal — range/eco/posicoes/auto-scroll/cursor "Agora"
|
||||
@@ -587,10 +628,19 @@ const settingsOpen = ref(false);
|
||||
// Pai so passa eventos brutos + workRules/settings/feriados via props,
|
||||
// e recebe @evento (pra abrir dialog) + @clear-filter (pra limpar tipo).
|
||||
|
||||
// Contagens por tipo + frase resumo do dia
|
||||
// Contagens por tipo + frase resumo do dia. Pra sessao tambem quebro
|
||||
// por status (cancelado/remarcado) pra montar o sufixo "(x foi cancelado,
|
||||
// x foi remarcado)" depois do chip de atendimentos.
|
||||
const contagensDia = computed(() => {
|
||||
const c = { sessao: 0, supervisao: 0, reuniao: 0 };
|
||||
for (const ev of eventosHojeReais.value) c[ev.tipo] = (c[ev.tipo] || 0) + 1;
|
||||
const c = { sessao: 0, supervisao: 0, reuniao: 0, sessaoCancelada: 0, sessaoRemarcada: 0 };
|
||||
for (const ev of eventosHojeReais.value) {
|
||||
c[ev.tipo] = (c[ev.tipo] || 0) + 1;
|
||||
if (ev.tipo === 'sessao') {
|
||||
const s = String(ev.status || '').toLowerCase();
|
||||
if (s === 'cancelado' || s === 'cancelada') c.sessaoCancelada += 1;
|
||||
else if (s === 'remarcado') c.sessaoRemarcada += 1;
|
||||
}
|
||||
}
|
||||
return c;
|
||||
});
|
||||
|
||||
@@ -598,11 +648,34 @@ function pluralizar(n, singular, plural) {
|
||||
return `${n} ${n === 1 ? singular : plural}`;
|
||||
}
|
||||
|
||||
// Sufixo "(1 foi cancelado, 2 foram remarcados)" depois do chip de
|
||||
// atendimentos quando houver sessoes cancel/remarcado no dia.
|
||||
function _statusSuffix(qtdCancel, qtdRemarc) {
|
||||
const partes = [];
|
||||
if (qtdCancel > 0) {
|
||||
partes.push(qtdCancel === 1
|
||||
? `${qtdCancel} foi cancelado`
|
||||
: `${qtdCancel} foram cancelados`);
|
||||
}
|
||||
if (qtdRemarc > 0) {
|
||||
partes.push(qtdRemarc === 1
|
||||
? `${qtdRemarc} foi remarcado`
|
||||
: `${qtdRemarc} foram remarcados`);
|
||||
}
|
||||
return partes.length ? ` (${partes.join(', ')})` : '';
|
||||
}
|
||||
|
||||
// Partes estruturadas pro template renderizar cada contagem como link clicável
|
||||
const resumoPartes = computed(() => {
|
||||
const c = contagensDia.value;
|
||||
const partes = [];
|
||||
if (c.sessao > 0) partes.push({ tipo: 'sessao', text: pluralizar(c.sessao, 'atendimento', 'atendimentos') });
|
||||
if (c.sessao > 0) {
|
||||
partes.push({
|
||||
tipo: 'sessao',
|
||||
text: pluralizar(c.sessao, 'atendimento', 'atendimentos'),
|
||||
suffix: _statusSuffix(c.sessaoCancelada, c.sessaoRemarcada)
|
||||
});
|
||||
}
|
||||
if (c.supervisao > 0) partes.push({ tipo: 'supervisao', text: pluralizar(c.supervisao, 'supervisão', 'supervisões') });
|
||||
if (c.reuniao > 0) partes.push({ tipo: 'reuniao', text: pluralizar(c.reuniao, 'reunião', 'reuniões') });
|
||||
return partes;
|
||||
@@ -1745,6 +1818,22 @@ function _callOnAgenda(action) {
|
||||
if (secaoAberta.value !== 'agenda') abrirSecao('agenda');
|
||||
}
|
||||
|
||||
// MelissaBusca @goto-date — usuario digitou "hoje"/"20/06" na busca
|
||||
// global. Abre a agenda se fechada (via _callOnAgenda que enfileira a
|
||||
// action ate o ref aparecer) e chama gotoDate exposto pela MelissaAgenda.
|
||||
function onBuscaGotoDate(date) {
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return;
|
||||
_callOnAgenda((agenda) => agenda.gotoDate?.(date));
|
||||
}
|
||||
|
||||
// Ref + provide pra qualquer secao filha pedir pra abrir a busca global
|
||||
// programaticamente. UI nao tem mais botao por secao (lupa unica fica
|
||||
// na .melissa-tray), mas o inject permanece exposto pra acoes contextuais
|
||||
// futuras (ex: "buscar paciente" num componente filho que quer abrir
|
||||
// o spotlight com query pre-preenchida).
|
||||
const melissaBuscaRef = ref(null);
|
||||
provide('openMelissaBusca', () => melissaBuscaRef.value?.openDialog?.());
|
||||
|
||||
function onAbrirProntuario() {
|
||||
const ev = eventoSelecionado.value;
|
||||
if (!ev?.patient_id) {
|
||||
@@ -1987,6 +2076,30 @@ const { toqueTermino, testarToque } = useMelissaToques('sino');
|
||||
function abrirCronometro() {
|
||||
cronoRef.value?.abrir();
|
||||
}
|
||||
|
||||
// Click no botao ⏱ de um evento da timeline (ou no CTA do card
|
||||
// "Proximo paciente" quando o evento esta em curso). Pre-seleciona
|
||||
// o paciente + autostart. Se ja houver cronometro rodando de outro
|
||||
// paciente, mostra toast sem trocar (opcao b decidida 2026-05-22).
|
||||
function onIniciarCronometroFromEvento(ev) {
|
||||
if (!ev?.patient_id) return;
|
||||
// Plano programado: horario original do evento na agenda. So passa se
|
||||
// os campos forem numericos validos — abrir() sanitiza de novo internamente.
|
||||
const sessionPlan = (typeof ev.startH === 'number' && typeof ev.endH === 'number')
|
||||
? { startH: ev.startH, endH: ev.endH }
|
||||
: null;
|
||||
const ret = cronoRef.value?.abrir({ pacienteId: ev.patient_id, autostart: true, sessionPlan });
|
||||
if (ret && !ret.opened && ret.alreadyRunning && !ret.samePaciente) {
|
||||
const atualNome = pacientesReais.value.find((p) => String(p.id) === String(ret.pacienteId))?.nome
|
||||
|| 'outro paciente';
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Cronômetro já ativo',
|
||||
detail: `Sessão de ${atualNome} em andamento. Pare o cronômetro atual antes de iniciar outro.`,
|
||||
life: 3500
|
||||
});
|
||||
}
|
||||
}
|
||||
function fecharCronometro() {
|
||||
cronoRef.value?.fechar();
|
||||
}
|
||||
@@ -2347,71 +2460,6 @@ function onKeydown(e) {
|
||||
<!-- PLANO DE TRÁS — Resumo (recebe blur quando workspace abre) -->
|
||||
<!-- ═══════════════════════════════════════════════════════ -->
|
||||
<div class="win11-summary" :class="{ 'is-behind': summaryDimmed }">
|
||||
<!-- Faixa de fundo do topbar — gradiente horizontal
|
||||
(cor solida na direita -> transparente na esquerda)
|
||||
pra dar legibilidade aos icones sem virar barra solida.
|
||||
Cor flipa com light/dark via --m-band. -->
|
||||
<div class="melissa-topbar-band" aria-hidden="true"></div>
|
||||
|
||||
<!-- Topbar Melissa (canto sup. direito): plan-DEV + notificações
|
||||
+ ajuda + cog. Os 3 primeiros vêm do AppTopbar — replicados
|
||||
aqui porque a rota /melissa é fullscreen e não monta o
|
||||
AppLayout. Drawer de notificações também é montado abaixo
|
||||
(AjudaDrawer já é global no App.vue). -->
|
||||
<div class="absolute top-5 right-5 z-30 flex items-center gap-2">
|
||||
<!-- Plan switcher DEV (só aparece em dev / com flag) -->
|
||||
<button
|
||||
v-if="showPlanDevMenu"
|
||||
ref="planBtn"
|
||||
class="glass-btn w-10 h-10 grid place-items-center"
|
||||
:disabled="planMenuLoading || trocandoPlano"
|
||||
title="Plano (DEV)"
|
||||
@click="openPlanMenu"
|
||||
>
|
||||
<i v-if="planMenuLoading || trocandoPlano" class="pi pi-spin pi-spinner text-white/90 text-base" />
|
||||
<i v-else class="pi pi-sliders-h text-white/90 text-base" />
|
||||
</button>
|
||||
|
||||
<!-- Notificações -->
|
||||
<button
|
||||
class="glass-btn w-10 h-10 grid place-items-center relative"
|
||||
title="Notificações"
|
||||
@click="notificationStore.drawerOpen = true"
|
||||
>
|
||||
<i class="pi pi-bell text-white/90 text-base" />
|
||||
<span
|
||||
v-if="notificationStore.unreadCount > 0"
|
||||
class="m-topbar-badge"
|
||||
>{{ notificationStore.unreadCount > 99 ? '99+' : notificationStore.unreadCount }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Ajuda -->
|
||||
<button
|
||||
class="glass-btn w-10 h-10 grid place-items-center"
|
||||
:class="{ 'glass-btn--active': ajudaDrawerOpen }"
|
||||
:title="ajudaDrawerOpen ? 'Fechar ajuda' : 'Ajuda'"
|
||||
@click="toggleAjuda"
|
||||
>
|
||||
<i class="pi pi-question-circle text-white/90 text-base" />
|
||||
</button>
|
||||
|
||||
<!-- Cog (settings popover) — existente -->
|
||||
<button
|
||||
class="glass-btn w-10 h-10 grid place-items-center"
|
||||
title="Personalizar"
|
||||
@click="settingsOpen = !settingsOpen"
|
||||
>
|
||||
<i class="pi pi-cog text-white/90 text-base" />
|
||||
</button>
|
||||
|
||||
<Transition name="settings-pop">
|
||||
<MelissaSettingsPanel
|
||||
v-if="settingsOpen"
|
||||
@close="settingsOpen = false"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo central -->
|
||||
<div class="win11-summary__inner">
|
||||
<!-- Bloco hero: relógio + data + saudação + resumo do dia -->
|
||||
@@ -2428,6 +2476,7 @@ function onKeydown(e) {
|
||||
|
||||
<!-- Busca rápida (entre o "Hoje há" e a timeline) -->
|
||||
<MelissaBusca
|
||||
ref="melissaBuscaRef"
|
||||
class="mt-8"
|
||||
:pacientes="pacientesReais"
|
||||
:eventos="eventosHojeReais"
|
||||
@@ -2436,6 +2485,7 @@ function onKeydown(e) {
|
||||
@evento="abrirEvento"
|
||||
@documento="(d) => d?.patient_id ? router.push({ path: '/melissa/paciente', query: { id: String(d.patient_id), tab: 'documentos' } }) : abrirSecao('pacientes')"
|
||||
@intake="() => abrirSecao('cadastros-recebidos')"
|
||||
@goto-date="onBuscaGotoDate"
|
||||
/>
|
||||
|
||||
<!-- Timeline horizontal + vertical (responsivo) -->
|
||||
@@ -2448,6 +2498,7 @@ function onKeydown(e) {
|
||||
:filtro-tipo="filtroTipo"
|
||||
@evento="abrirEvento"
|
||||
@clear-filter="limparFiltro"
|
||||
@iniciar-cronometro="onIniciarCronometroFromEvento"
|
||||
/>
|
||||
|
||||
<!-- Cards (catálogo + ativos + layout switchável) -->
|
||||
@@ -2460,14 +2511,24 @@ function onKeydown(e) {
|
||||
:class="cardsLayout === 'linha-unica' ? 'cards-row--linha' : 'cards-row--wrap'"
|
||||
>
|
||||
<template v-for="cardId in cardsAtivos" :key="cardId">
|
||||
<!-- Próximo paciente -->
|
||||
<!-- Próximo paciente. Se o evento esta em curso E tem
|
||||
paciente, action vira "Iniciar cronômetro" pra
|
||||
facilitar o fluxo "estou comecando a sessao agora". -->
|
||||
<MelissaCard
|
||||
v-if="cardId === 'proximo-paciente'"
|
||||
icon="pi pi-user"
|
||||
icon-color="text-emerald-300"
|
||||
title="Próximo paciente"
|
||||
:action-title="proximoPaciente ? 'Abrir sessão' : 'Abrir Pacientes'"
|
||||
@open="proximoPaciente ? abrirEvento(proximoPaciente.ev) : abrirSecao('pacientes')"
|
||||
:action-title="proximoPaciente
|
||||
? (proximoPaciente.emCurso && proximoPaciente.ev?.patient_id
|
||||
? 'Iniciar cronômetro'
|
||||
: 'Abrir sessão')
|
||||
: 'Abrir Pacientes'"
|
||||
@open="proximoPaciente
|
||||
? (proximoPaciente.emCurso && proximoPaciente.ev?.patient_id
|
||||
? onIniciarCronometroFromEvento(proximoPaciente.ev)
|
||||
: abrirEvento(proximoPaciente.ev))
|
||||
: abrirSecao('pacientes')"
|
||||
>
|
||||
<div v-if="proximoPaciente" class="flex items-center gap-3">
|
||||
<div
|
||||
@@ -2613,14 +2674,163 @@ function onKeydown(e) {
|
||||
<span class="psi-kbd" aria-hidden="true">Ctrl \</span>
|
||||
</button>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════ -->
|
||||
<!-- TRAY Melissa (system tray win11-style, canto inf. direito) -->
|
||||
<!-- busca + plan-DEV + notificações + ajuda + cog. Sibling de -->
|
||||
<!-- .dock (fora de .win11-summary) pra ficar sempre interativo,-->
|
||||
<!-- mesmo com secao aberta (que aplica blur+pointer-none na -->
|
||||
<!-- summary). AjudaDrawer ja e global no App.vue. -->
|
||||
<!-- ═══════════════════════════════════════════════════════ -->
|
||||
<div class="melissa-tray">
|
||||
<!-- Busca global (Ctrl+K) — afordancia visivel pra mouse/touch.
|
||||
Centraliza o ponto de acesso entre seccoes (em vez de
|
||||
cada toolbar ter o proprio botao). -->
|
||||
<button
|
||||
class="glass-btn w-10 h-10 grid place-items-center"
|
||||
v-tooltip.top="'Buscar (Ctrl+K)'"
|
||||
aria-label="Busca global"
|
||||
@click="melissaBuscaRef?.openDialog?.()"
|
||||
>
|
||||
<i class="pi pi-search text-white/90 text-base" />
|
||||
</button>
|
||||
|
||||
<!-- Plan switcher DEV (só aparece em dev / com flag).
|
||||
Em mobile (<md) some — vai pro popup do "more". -->
|
||||
<button
|
||||
v-if="showPlanDevMenu"
|
||||
ref="planBtn"
|
||||
class="glass-btn w-10 h-10 hidden md:grid place-items-center"
|
||||
:disabled="planMenuLoading || trocandoPlano"
|
||||
v-tooltip.top="'Plano (DEV)'"
|
||||
@click="openPlanMenu"
|
||||
>
|
||||
<i v-if="planMenuLoading || trocandoPlano" class="pi pi-spin pi-spinner text-white/90 text-base" />
|
||||
<i v-else class="pi pi-sliders-h text-white/90 text-base" />
|
||||
</button>
|
||||
|
||||
<!-- Notificações — em mobile (<md) some, vai pro popup. -->
|
||||
<button
|
||||
class="glass-btn w-10 h-10 hidden md:grid place-items-center relative"
|
||||
v-tooltip.top="'Notificações'"
|
||||
@click="notificationStore.drawerOpen = true"
|
||||
>
|
||||
<i class="pi pi-bell text-white/90 text-base" />
|
||||
<span
|
||||
v-if="notificationStore.unreadCount > 0"
|
||||
class="m-topbar-badge"
|
||||
>{{ notificationStore.unreadCount > 99 ? '99+' : notificationStore.unreadCount }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Ajuda (conteudo muda com a rota via AjudaDrawer global).
|
||||
data-ajuda-toggle: marca pro AjudaDrawer ignorar este botao
|
||||
na deteccao de "clicou fora pra fechar" (senao fecha aqui
|
||||
e o @click reabre). Em mobile (<md) some, vai pro popup. -->
|
||||
<button
|
||||
class="glass-btn w-10 h-10 hidden md:grid place-items-center"
|
||||
:class="{ 'glass-btn--active': ajudaDrawerOpen }"
|
||||
v-tooltip.top="ajudaDrawerOpen ? 'Fechar ajuda' : 'Ajuda'"
|
||||
data-ajuda-toggle
|
||||
@click="toggleAjuda"
|
||||
>
|
||||
<i class="pi pi-question-circle text-white/90 text-base" />
|
||||
</button>
|
||||
|
||||
<!-- Cog (settings popover abre pra CIMA — vide MelissaSettingsPanel).
|
||||
Em mobile (<md) some, vai pro popup. -->
|
||||
<button
|
||||
ref="cogBtnEl"
|
||||
class="glass-btn w-10 h-10 hidden md:grid place-items-center"
|
||||
v-tooltip.top="'Personalizar'"
|
||||
@click="settingsOpen = !settingsOpen"
|
||||
>
|
||||
<i class="pi pi-cog text-white/90 text-base" />
|
||||
</button>
|
||||
|
||||
<Transition name="settings-pop">
|
||||
<MelissaSettingsPanel
|
||||
v-if="settingsOpen"
|
||||
@close="settingsOpen = false"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- "More" — so em mobile (<md). Collapse de plan-DEV+bell+help+
|
||||
cog num popup vertical. Dot vermelho aparece se houver
|
||||
notificacoes nao-lidas (preserva o sinal visual da bell). -->
|
||||
<button
|
||||
ref="trayMoreBtnEl"
|
||||
class="glass-btn w-10 h-10 grid md:hidden place-items-center relative"
|
||||
:class="{ 'glass-btn--active': trayMoreOpen }"
|
||||
v-tooltip.top="trayMoreOpen ? 'Fechar' : 'Mais'"
|
||||
aria-label="Mais opcoes"
|
||||
@click="trayMoreOpen = !trayMoreOpen"
|
||||
>
|
||||
<i class="pi pi-ellipsis-v text-white/90 text-base" />
|
||||
<span
|
||||
v-if="notificationStore.unreadCount > 0"
|
||||
class="melissa-tray__more-dot"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Transition name="settings-pop">
|
||||
<div v-if="trayMoreOpen" class="melissa-tray__more-panel glass-panel">
|
||||
<!-- Notificacoes -->
|
||||
<button
|
||||
class="melissa-tray__more-item"
|
||||
@click="notificationStore.drawerOpen = true; trayMoreOpen = false"
|
||||
>
|
||||
<i class="pi pi-bell" />
|
||||
<span class="flex-1 text-left">Notificações</span>
|
||||
<span
|
||||
v-if="notificationStore.unreadCount > 0"
|
||||
class="m-topbar-badge"
|
||||
style="position: static; transform: none;"
|
||||
>{{ notificationStore.unreadCount > 99 ? '99+' : notificationStore.unreadCount }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Ajuda -->
|
||||
<button
|
||||
class="melissa-tray__more-item"
|
||||
:class="{ 'is-active': ajudaDrawerOpen }"
|
||||
data-ajuda-toggle
|
||||
@click="toggleAjuda(); trayMoreOpen = false"
|
||||
>
|
||||
<i class="pi pi-question-circle" />
|
||||
<span class="flex-1 text-left">{{ ajudaDrawerOpen ? 'Fechar ajuda' : 'Ajuda' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Personalizar (cog) -->
|
||||
<button
|
||||
class="melissa-tray__more-item"
|
||||
@click="settingsOpen = !settingsOpen; trayMoreOpen = false"
|
||||
>
|
||||
<i class="pi pi-cog" />
|
||||
<span class="flex-1 text-left">Personalizar</span>
|
||||
</button>
|
||||
|
||||
<!-- Plano DEV (so se flag) -->
|
||||
<button
|
||||
v-if="showPlanDevMenu"
|
||||
class="melissa-tray__more-item"
|
||||
:disabled="planMenuLoading || trocandoPlano"
|
||||
@click="openPlanMenu($event); trayMoreOpen = false"
|
||||
>
|
||||
<i :class="planMenuLoading || trocandoPlano ? 'pi pi-spin pi-spinner' : 'pi pi-sliders-h'" />
|
||||
<span class="flex-1 text-left">Plano (DEV)</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════ -->
|
||||
<!-- DOCK (taskbar Win11-style sem chrome) — receptáculo pra -->
|
||||
<!-- elementos minimizados (cronômetro, futuros) + atalhos -->
|
||||
<!-- pinned (Agenda, WhatsApp). Transparent, só os items são -->
|
||||
<!-- clicáveis. ψ vive ao lado (absolute, bottom-left). -->
|
||||
<!-- pinned (Agenda, Pacientes, WhatsApp, Financeiro). Transp.-->
|
||||
<!-- só os items sao clicaveis. ψ vive ao lado (absolute, -->
|
||||
<!-- bottom-left). -->
|
||||
<!-- ═══════════════════════════════════════════════════════ -->
|
||||
<div class="melissa-dock">
|
||||
<!-- Pinned: atalhos diretos pras seções mais usadas.
|
||||
<!-- Pinned builtins: 4 atalhos pras secoes principais.
|
||||
Tamanho menor que ψ (44px vs 56px) + cantos arredondados
|
||||
mas não full-circle, pra hierarquia visual ficar óbvia. -->
|
||||
<button
|
||||
@@ -2632,6 +2842,15 @@ function onKeydown(e) {
|
||||
>
|
||||
<i class="pi pi-calendar" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dock-pin"
|
||||
v-tooltip.top="'Pacientes'"
|
||||
:class="{ 'dock-pin--active': secaoAberta === 'pacientes' }"
|
||||
@click="abrirSecao('pacientes')"
|
||||
>
|
||||
<i class="pi pi-users" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dock-pin"
|
||||
@@ -2646,12 +2865,24 @@ function onKeydown(e) {
|
||||
:title="`${whatsappPendente.count} mensagens não lidas`"
|
||||
>{{ whatsappPendente.count > 99 ? '99+' : whatsappPendente.count }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dock-pin"
|
||||
v-tooltip.top="'Financeiro'"
|
||||
:class="{ 'dock-pin--active': secaoAberta === 'financeiro' }"
|
||||
@click="abrirSecao('financeiro')"
|
||||
>
|
||||
<i class="pi pi-wallet" />
|
||||
</button>
|
||||
|
||||
<!-- Divisor entre builtins e pins dinâmicos. Só aparece se
|
||||
o user tem pelo menos 1 pin (fixo ou recente). -->
|
||||
o user tem pelo menos 1 pin (fixo ou recente).
|
||||
Em mobile (<md), se so houver MRU (que e oculto), o
|
||||
divisor tambem some pra nao "soltar" no fim do dock. -->
|
||||
<div
|
||||
v-if="dockPins.pinned.value.length || dockPins.recent.value.length"
|
||||
class="dock-divider"
|
||||
:class="{ 'hidden md:block': !dockPins.pinned.value.length }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
@@ -2671,7 +2902,11 @@ function onKeydown(e) {
|
||||
</button>
|
||||
|
||||
<!-- Pins MRU (max 3) — empurrados pelas últimas seções abertas.
|
||||
Visual mais leve (opacity menor) pra destacar dos fixos. -->
|
||||
Visual mais leve (opacity menor) pra destacar dos fixos.
|
||||
Em mobile (<md=768px) sao ocultos via media query no CSS
|
||||
do .dock-pin--recent — utility 'hidden' do Tailwind perde
|
||||
pro 'display: grid' base do .dock-pin (mesma specificity,
|
||||
ordem de carga). -->
|
||||
<button
|
||||
v-for="slug in dockPins.recent.value" :key="`r-${slug}`"
|
||||
type="button"
|
||||
@@ -3856,7 +4091,7 @@ function onKeydown(e) {
|
||||
.settings-pop-enter-from,
|
||||
.settings-pop-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.98);
|
||||
transform: translateY(6px) scale(0.98);
|
||||
}
|
||||
|
||||
/* line-clamp util (caso Tailwind não tenha) */
|
||||
@@ -4011,24 +4246,88 @@ function onKeydown(e) {
|
||||
--m-hero-text-border: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
/* ─── Faixa de fundo do topbar (canto sup. direito) ──────────────
|
||||
Gradiente horizontal: cor solida na direita (onde os icones vivem)
|
||||
e fade pra transparente na esquerda. z-index abaixo do topbar
|
||||
(z-30) e acima do conteudo principal. */
|
||||
.melissa-topbar-band {
|
||||
/* ─── Tray Melissa (system tray win11-style, canto inferior direito)
|
||||
Posiciona o grupo de icones globais (plan-DEV, notificacoes, ajuda,
|
||||
cog) alinhado verticalmente com os pins do dock. z-index acima do
|
||||
dock (65) pra ficar no mesmo plano interativo. */
|
||||
.melissa-tray {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 80px;
|
||||
z-index: 25;
|
||||
bottom: 0;
|
||||
right: 1.25rem;
|
||||
height: var(--m-dock-h, 76px);
|
||||
z-index: 66;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Dot vermelho no botao "More" da tray (mobile) — sinaliza que ha
|
||||
notificacoes nao-lidas escondidas dentro do popup. */
|
||||
.melissa-tray__more-dot {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 9999px;
|
||||
background: rgb(239, 68, 68);
|
||||
border: 1.5px solid var(--m-bg-medium, rgba(0, 0, 0, 0.4));
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to left,
|
||||
var(--m-band) 0%,
|
||||
var(--m-band) 25%,
|
||||
transparent 75%
|
||||
);
|
||||
}
|
||||
|
||||
/* Popup vertical do "More" — abre acima do botao trigger, mesmo padrao
|
||||
visual do MelissaSettingsPanel mas mais compacto. */
|
||||
.melissa-tray__more-panel {
|
||||
position: absolute;
|
||||
bottom: calc(var(--m-dock-h, 76px) - 8px);
|
||||
right: 0;
|
||||
min-width: 220px;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
border-radius: 14px;
|
||||
z-index: 67;
|
||||
}
|
||||
|
||||
.melissa-tray__more-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--m-text);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.88rem;
|
||||
text-align: left;
|
||||
transition: background-color 140ms ease, color 140ms ease;
|
||||
}
|
||||
.melissa-tray__more-item:hover:not(:disabled),
|
||||
.melissa-tray__more-item:focus-visible {
|
||||
background: var(--m-bg-soft-hover);
|
||||
outline: none;
|
||||
}
|
||||
.melissa-tray__more-item.is-active {
|
||||
background: var(--m-bg-soft-hover);
|
||||
color: white;
|
||||
}
|
||||
.melissa-tray__more-item:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.melissa-tray__more-item > i {
|
||||
font-size: 0.95rem;
|
||||
width: 16px;
|
||||
color: var(--m-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.melissa-tray__more-item:hover > i,
|
||||
.melissa-tray__more-item.is-active > i {
|
||||
color: var(--m-text);
|
||||
}
|
||||
|
||||
/* ─── Dock (global pra atravessar Teleport + evitar perda de scoped
|
||||
@@ -4165,6 +4464,13 @@ html:not(.app-dark) .dock-divider {
|
||||
.dock-pin--recent.dock-pin--active {
|
||||
opacity: 1;
|
||||
}
|
||||
/* Em mobile (<md=768px) os pins MRU somem pra economizar largura
|
||||
(4 builtins + user-fixed pins ja saturam o dock). Media query no
|
||||
bloco do .dock-pin--recent ganha do utility 'hidden' do Tailwind
|
||||
por ordem de carga (mesma specificity). */
|
||||
@media (max-width: 767px) {
|
||||
.dock-pin--recent { display: none; }
|
||||
}
|
||||
|
||||
/* ─── Skeleton loading utilitário (global pra atravessar scoped CSS dos
|
||||
componentes filhos). Use a classe .melissa-skeleton em qualquer
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -313,6 +313,9 @@ async function refetchTudo() {
|
||||
}
|
||||
|
||||
// ── Estado de UI ───────────────────────────────────────────────
|
||||
// Busca local — filtra a lista visivel combinada com filtros de
|
||||
// status/grupo/tag. Busca global (Ctrl+K) tem botao dedicado no
|
||||
// .melissa-tray, fora desta seccao.
|
||||
const busca = ref('');
|
||||
const statusFiltro = ref('ativos'); // 'todos' | 'ativos' | 'inativos' | 'arquivados'
|
||||
const grupoFiltroId = ref(null); // null = todos
|
||||
|
||||
@@ -0,0 +1,763 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — MelissaPatientDocuments
|
||||
|--------------------------------------------------------------------------
|
||||
| Página nativa Melissa pra aba "Documentos" do prontuário (substitui o
|
||||
| embed do DocumentsListPage).
|
||||
|
|
||||
| Layout 2-col (espelha MelissaDocumentosTemplates):
|
||||
| - COL 1 (esquerda): tipos de documento como sidebar com contadores
|
||||
| - COL 2 (direita): DataView dos documentos do tipo selecionado,
|
||||
| com upload/preview/edit/sign/share/delete via dialogs reaproveitados
|
||||
| - Mobile (<1024px): sidebar vira drawer (botão "Tipos" no header)
|
||||
|
|
||||
| Reaproveita do feature/documents:
|
||||
| - useDocuments composable (fetch + CRUD + URLs)
|
||||
| - DocumentCard pra item visual
|
||||
| - DocumentUploadDialog / PreviewDialog / GenerateDialog /
|
||||
| SignatureDialog / ShareDialog
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import Menu from 'primevue/menu';
|
||||
import DataView from 'primevue/dataview';
|
||||
|
||||
import { useDocuments } from '@/features/documents/composables/useDocuments';
|
||||
import DocumentCard from '@/features/documents/components/DocumentCard.vue';
|
||||
import DocumentUploadDialog from '@/features/documents/components/DocumentUploadDialog.vue';
|
||||
import DocumentPreviewDialog from '@/features/documents/components/DocumentPreviewDialog.vue';
|
||||
import DocumentGenerateDialog from '@/features/documents/components/DocumentGenerateDialog.vue';
|
||||
import DocumentSignatureDialog from '@/features/documents/components/DocumentSignatureDialog.vue';
|
||||
import DocumentShareDialog from '@/features/documents/components/DocumentShareDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
patientId: { type: String, default: null },
|
||||
patientName: { type: String, default: '' }
|
||||
});
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
|
||||
// ── useDocuments composable ───────────────────────────────
|
||||
const {
|
||||
documents, loading, error, filters, usedTags, stats,
|
||||
TIPOS_DOCUMENTO,
|
||||
fetchDocuments, upload, update, remove,
|
||||
download, getPreviewUrl, fetchUsedTags
|
||||
} = useDocuments(() => props.patientId);
|
||||
|
||||
// ── Dialogs ─────────────────────────────────────────────────
|
||||
const uploadDlg = ref(false);
|
||||
const previewDlg = ref(false);
|
||||
const generateDlg = ref(false);
|
||||
const signatureDlg = ref(false);
|
||||
const shareDlg = ref(false);
|
||||
const selectedDoc = ref(null);
|
||||
const previewUrl = ref('');
|
||||
// Ref dedicado pro modo edicao do generate dialog. Separado do selectedDoc
|
||||
// (que tambem alimenta preview/share/sign/delete) pra evitar vazar "edit
|
||||
// state" pro "Gerar" do header quando o user so abre preview e fecha.
|
||||
const editingDoc = ref(null);
|
||||
|
||||
// ── Tipo selecionado (filtro pela sidebar) ────────────────
|
||||
// null = todos os tipos
|
||||
const selectedTipo = ref(null);
|
||||
|
||||
const docsByTipo = computed(() => {
|
||||
const groups = {};
|
||||
for (const d of documents.value) {
|
||||
const tipo = d.tipo_documento || 'outro';
|
||||
if (!groups[tipo]) groups[tipo] = [];
|
||||
groups[tipo].push(d);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
const tipoCounts = computed(() => {
|
||||
const counts = {};
|
||||
TIPOS_DOCUMENTO.forEach(t => { counts[t.value] = (docsByTipo.value[t.value] || []).length; });
|
||||
return counts;
|
||||
});
|
||||
|
||||
const filteredDocs = computed(() => {
|
||||
if (!selectedTipo.value) return documents.value;
|
||||
return docsByTipo.value[selectedTipo.value] || [];
|
||||
});
|
||||
|
||||
function tipoLabel(tipo) {
|
||||
return TIPOS_DOCUMENTO.find(t => t.value === tipo)?.label || tipo;
|
||||
}
|
||||
|
||||
// ── Mobile drawer ─────────────────────────────────────────
|
||||
const drawerOpen = ref(false);
|
||||
const isMobile = ref(false);
|
||||
let _mqMobile = null;
|
||||
function _onMqMobileChange(e) {
|
||||
isMobile.value = e.matches;
|
||||
if (!e.matches) drawerOpen.value = false;
|
||||
}
|
||||
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
|
||||
function fecharDrawer() { drawerOpen.value = false; }
|
||||
function selectTipo(tipo) {
|
||||
selectedTipo.value = tipo;
|
||||
if (isMobile.value) drawerOpen.value = false;
|
||||
}
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────
|
||||
async function onUploaded({ file, meta }) {
|
||||
try {
|
||||
await upload(file, props.patientId, meta);
|
||||
toast.add({ severity: 'success', summary: 'Enviado', detail: file.name, life: 3000 });
|
||||
fetchUsedTags();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro no upload', detail: e?.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function onPreview(doc) {
|
||||
selectedDoc.value = doc;
|
||||
try { previewUrl.value = await getPreviewUrl(doc); }
|
||||
catch { previewUrl.value = ''; }
|
||||
previewDlg.value = true;
|
||||
}
|
||||
|
||||
function onDownload(doc) { download(doc); }
|
||||
|
||||
function onEdit(doc) {
|
||||
// Abre o DocumentGenerateDialog em modo edicao (editingDocId passado).
|
||||
// Dialog busca template + dados_preenchidos do document_generated e
|
||||
// pre-popula tudo, pulando direto pra step 'edit'. Save substitui o PDF
|
||||
// in-place no Storage e atualiza documents (preserva id + audit trail).
|
||||
// Docs uploaded direto (sem registro generated) caem no flow normal de
|
||||
// "select template" com um toast info.
|
||||
editingDoc.value = doc;
|
||||
previewDlg.value = false; // fecha preview se estiver aberto
|
||||
generateDlg.value = true;
|
||||
}
|
||||
|
||||
function onDelete(doc) {
|
||||
confirm.require({
|
||||
header: 'Excluir documento',
|
||||
message: `Excluir "${doc.nome_original}"? Esta ação pode ser revertida no Lixo.`,
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Excluir',
|
||||
rejectLabel: 'Cancelar',
|
||||
acceptSeverity: 'danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
await remove(doc.id);
|
||||
toast.add({ severity: 'success', summary: 'Excluído', detail: doc.nome_original, life: 3000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao excluir', detail: e?.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onShare(doc) {
|
||||
selectedDoc.value = doc;
|
||||
shareDlg.value = true;
|
||||
}
|
||||
function onSign(doc) {
|
||||
selectedDoc.value = doc;
|
||||
signatureDlg.value = true;
|
||||
}
|
||||
function onGenerated() {
|
||||
fetchDocuments();
|
||||
fetchUsedTags();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
_mqMobile = window.matchMedia('(max-width: 1023px)');
|
||||
isMobile.value = _mqMobile.matches;
|
||||
try { _mqMobile.addEventListener('change', _onMqMobileChange); }
|
||||
catch { _mqMobile.addListener(_onMqMobileChange); }
|
||||
}
|
||||
await Promise.all([fetchDocuments(), fetchUsedTags()]);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (_mqMobile) {
|
||||
try { _mqMobile.removeEventListener('change', _onMqMobileChange); }
|
||||
catch { _mqMobile.removeListener(_onMqMobileChange); }
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Mobile drawer (tipos) — teleportado pro body pra escapar de
|
||||
stacking contexts pais (transform/filter no MelissaPaciente
|
||||
travavam o fixed). win11-root no wrapper pra herdar os
|
||||
tokens --m-* definidos nesse escopo. -->
|
||||
<Teleport to="body">
|
||||
<div class="win11-root mpd-drawer-portal">
|
||||
<Transition name="mpd-drawer-fade">
|
||||
<div
|
||||
v-show="isMobile && drawerOpen"
|
||||
class="mpd-mobile-drawer"
|
||||
:class="{ 'is-open': drawerOpen }"
|
||||
>
|
||||
<div id="mpd-mobile-drawer-target" class="mpd-mobile-drawer__scroll" />
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition name="mpd-drawer-fade">
|
||||
<div
|
||||
v-show="isMobile && drawerOpen"
|
||||
class="mpd-mobile-drawer__backdrop"
|
||||
@click="fecharDrawer"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<section class="mpd-page">
|
||||
<!-- ── HEADER ─────────────────────────────────────── -->
|
||||
<header class="mpd-page__head">
|
||||
<button
|
||||
class="mpd-menu-btn mpd-menu-btn--mobile-only"
|
||||
v-tooltip.bottom="'Tipos de documento'"
|
||||
@click="toggleDrawer"
|
||||
>
|
||||
<i class="pi pi-bars" />
|
||||
<span>Tipos</span>
|
||||
</button>
|
||||
<div class="mpd-page__title">
|
||||
<i class="pi pi-file mpd-page__title-icon" />
|
||||
<span>Documentos</span>
|
||||
<span class="mpd-page__count">{{ documents.length }}</span>
|
||||
</div>
|
||||
<div class="mpd-page__actions">
|
||||
<button
|
||||
class="mpd-act-btn"
|
||||
v-tooltip.bottom="'Atualizar'"
|
||||
:disabled="loading"
|
||||
@click="fetchDocuments"
|
||||
>
|
||||
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'" />
|
||||
</button>
|
||||
<button
|
||||
class="mpd-act-btn"
|
||||
v-tooltip.bottom="'Gerar a partir de template'"
|
||||
:disabled="!patientId"
|
||||
@click="generateDlg = true"
|
||||
>
|
||||
<i class="pi pi-file-pdf" />
|
||||
<span>Gerar</span>
|
||||
</button>
|
||||
<button
|
||||
class="mpd-act-btn mpd-act-btn--primary"
|
||||
v-tooltip.bottom="'Enviar arquivo'"
|
||||
:disabled="!patientId"
|
||||
@click="uploadDlg = true"
|
||||
>
|
||||
<i class="pi pi-upload" />
|
||||
<span>Upload</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── BODY 2-col ─────────────────────────────────── -->
|
||||
<div class="mpd-body">
|
||||
<!-- COL 1 — Sidebar de tipos (teleporta no mobile) -->
|
||||
<Teleport to="#mpd-mobile-drawer-target" :disabled="!isMobile">
|
||||
<aside class="mpd-side">
|
||||
<div class="mpd-side__head">
|
||||
<i class="pi pi-folder" />
|
||||
<span>Tipos</span>
|
||||
</div>
|
||||
<ul class="mpd-side__list">
|
||||
<li
|
||||
class="mpd-side__item"
|
||||
:class="{ 'is-active': selectedTipo === null }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="selectTipo(null)"
|
||||
@keydown.enter.prevent="selectTipo(null)"
|
||||
>
|
||||
<span class="mpd-side__item-name">Todos</span>
|
||||
<span class="mpd-side__item-count">{{ documents.length }}</span>
|
||||
</li>
|
||||
<li
|
||||
v-for="t in TIPOS_DOCUMENTO"
|
||||
:key="t.value"
|
||||
class="mpd-side__item"
|
||||
:class="{ 'is-active': selectedTipo === t.value, 'is-empty': tipoCounts[t.value] === 0 }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="selectTipo(t.value)"
|
||||
@keydown.enter.prevent="selectTipo(t.value)"
|
||||
>
|
||||
<span class="mpd-side__item-name">{{ t.label }}</span>
|
||||
<span class="mpd-side__item-count">{{ tipoCounts[t.value] || 0 }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</Teleport>
|
||||
|
||||
<!-- COL 2 — Main: lista de documentos -->
|
||||
<main class="mpd-main">
|
||||
<header class="mpd-main__head">
|
||||
<div class="mpd-main__title-row">
|
||||
<div class="mpd-main__title">
|
||||
<i class="pi pi-file" />
|
||||
<span>{{ selectedTipo ? tipoLabel(selectedTipo) : 'Todos os documentos' }}</span>
|
||||
</div>
|
||||
<span class="mpd-page__count">{{ filteredDocs.length }}</span>
|
||||
</div>
|
||||
<p class="mpd-main__subtitle">
|
||||
<template v-if="selectedTipo">
|
||||
Documentos do tipo <strong>{{ tipoLabel(selectedTipo) }}</strong> deste paciente.
|
||||
</template>
|
||||
<template v-else>
|
||||
Todos os documentos clínicos vinculados a este paciente.
|
||||
</template>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading && !documents.length" class="mpd-loading">
|
||||
<i class="pi pi-spin pi-spinner" />
|
||||
<span>Carregando documentos…</span>
|
||||
</div>
|
||||
|
||||
<!-- Empty global -->
|
||||
<div v-else-if="!documents.length" class="mpd-empty">
|
||||
<i class="pi pi-folder-open mpd-empty__icon" />
|
||||
<div class="mpd-empty__title">Nenhum documento ainda</div>
|
||||
<div class="mpd-empty__hint">
|
||||
Faça upload de um arquivo ou gere um documento a partir de template.
|
||||
</div>
|
||||
<div class="mpd-empty__actions">
|
||||
<button class="mpd-act-btn" @click="generateDlg = true">
|
||||
<i class="pi pi-file-pdf" /><span>Gerar template</span>
|
||||
</button>
|
||||
<button class="mpd-act-btn mpd-act-btn--primary" @click="uploadDlg = true">
|
||||
<i class="pi pi-upload" /><span>Upload</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty (filtrado) -->
|
||||
<div v-else-if="!filteredDocs.length" class="mpd-empty">
|
||||
<i class="pi pi-filter mpd-empty__icon" />
|
||||
<div class="mpd-empty__title">Nenhum {{ tipoLabel(selectedTipo).toLowerCase() }} ainda</div>
|
||||
<div class="mpd-empty__hint">
|
||||
Outros tipos têm documentos —
|
||||
<button class="mpd-link" @click="selectTipo(null)">ver todos</button>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DataView -->
|
||||
<DataView
|
||||
v-else
|
||||
:value="filteredDocs"
|
||||
layout="grid"
|
||||
:paginator="filteredDocs.length > 12"
|
||||
:rows="12"
|
||||
class="mpd-dataview"
|
||||
>
|
||||
<template #grid="slotProps">
|
||||
<div class="mpd-grid">
|
||||
<DocumentCard
|
||||
v-for="doc in slotProps.items"
|
||||
:key="doc.id"
|
||||
:doc="doc"
|
||||
@preview="onPreview"
|
||||
@download="onDownload"
|
||||
@edit="onEdit"
|
||||
@delete="onDelete"
|
||||
@share="onShare"
|
||||
@sign="onSign"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</DataView>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ── Dialogs reaproveitados ─────────────────────── -->
|
||||
<DocumentUploadDialog
|
||||
v-model:visible="uploadDlg"
|
||||
:patient-id="patientId"
|
||||
@uploaded="onUploaded"
|
||||
/>
|
||||
<DocumentPreviewDialog
|
||||
v-model:visible="previewDlg"
|
||||
:doc="selectedDoc"
|
||||
:preview-url="previewUrl"
|
||||
@updated="fetchDocuments"
|
||||
@download="onDownload"
|
||||
@edit="onEdit"
|
||||
@share="(d) => { previewDlg = false; onShare(d); }"
|
||||
@sign="(d) => { previewDlg = false; onSign(d); }"
|
||||
@delete="(d) => { previewDlg = false; onDelete(d); }"
|
||||
/>
|
||||
<!-- editing-doc-id vem do ref editingDoc dedicado — so e setado
|
||||
via onEdit (botao Editar). "Gerar" no header usa generateDlg=true
|
||||
com editingDoc=null, abrindo limpo. Limpa editingDoc no
|
||||
fechamento pra nao vazar pro proximo Gerar. -->
|
||||
<DocumentGenerateDialog
|
||||
v-if="patientId"
|
||||
:visible="generateDlg"
|
||||
:patient-id="patientId"
|
||||
:patient-name="patientName"
|
||||
:editing-doc-id="editingDoc?.id || null"
|
||||
@generated="onGenerated"
|
||||
@update:visible="(v) => { generateDlg = v; if (!v) editingDoc = null; }"
|
||||
/>
|
||||
<DocumentSignatureDialog
|
||||
:visible="signatureDlg"
|
||||
@update:visible="signatureDlg = $event"
|
||||
:doc="selectedDoc"
|
||||
/>
|
||||
<DocumentShareDialog
|
||||
v-model:visible="shareDlg"
|
||||
:doc="selectedDoc"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ═══════ Page chrome ═══════ */
|
||||
.mpd-page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--m-bg-medium);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
color: var(--m-text);
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.mpd-page__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--m-border);
|
||||
flex-shrink: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
.mpd-page__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mpd-page__title-icon { color: var(--p-primary-color); font-size: 1rem; }
|
||||
.mpd-page__count {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--m-accent);
|
||||
background: var(--m-accent-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--m-accent) 35%, transparent);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.mpd-page__actions { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
||||
|
||||
.mpd-act-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
transition: background-color 140ms ease;
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
color: var(--m-text);
|
||||
}
|
||||
.mpd-act-btn:hover:not(:disabled) { background: var(--m-bg-soft-hover); }
|
||||
.mpd-act-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.mpd-act-btn--primary {
|
||||
background: transparent;
|
||||
border-color: var(--p-primary-color);
|
||||
color: var(--p-primary-color);
|
||||
}
|
||||
.mpd-act-btn--primary:hover:not(:disabled) { background: color-mix(in srgb, var(--p-primary-color) 10%, transparent); }
|
||||
.mpd-act-btn > i { font-size: 0.78rem; }
|
||||
|
||||
.mpd-menu-btn {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--m-border);
|
||||
background: var(--m-bg-soft);
|
||||
color: var(--m-text);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mpd-menu-btn:hover { background: var(--m-bg-soft-hover); }
|
||||
|
||||
/* ═══════ Body 2-col ═══════ */
|
||||
.mpd-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 260px) 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* COL 1 — Sidebar */
|
||||
.mpd-side {
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mpd-side__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--m-border);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mpd-side__head > i { color: var(--m-text-muted); font-size: 0.9rem; }
|
||||
.mpd-side__list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
list-style: none;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.mpd-side__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: transparent;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
.mpd-side__item:hover,
|
||||
.mpd-side__item:focus-visible {
|
||||
background: var(--m-bg-soft-hover);
|
||||
outline: none;
|
||||
}
|
||||
.mpd-side__item.is-active {
|
||||
background: color-mix(in srgb, var(--p-primary-color) 12%, var(--m-bg-medium));
|
||||
color: var(--p-primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
.mpd-side__item.is-empty { opacity: 0.5; }
|
||||
.mpd-side__item-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mpd-side__item-count {
|
||||
font-size: 0.7rem;
|
||||
color: var(--m-text-muted);
|
||||
background: var(--m-bg-medium);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mpd-side__item.is-active .mpd-side__item-count {
|
||||
color: var(--p-primary-color);
|
||||
background: color-mix(in srgb, var(--p-primary-color) 18%, transparent);
|
||||
}
|
||||
|
||||
/* COL 2 — Main */
|
||||
.mpd-main {
|
||||
background: var(--m-bg-soft);
|
||||
border: 1px solid var(--m-border);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mpd-main__head {
|
||||
padding: 12px 14px 8px;
|
||||
border-bottom: 1px solid var(--m-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mpd-main__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.mpd-main__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.mpd-main__title > i { color: var(--p-primary-color); font-size: 0.9rem; }
|
||||
.mpd-main__subtitle {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.74rem;
|
||||
color: var(--m-text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.mpd-loading,
|
||||
.mpd-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 28px;
|
||||
text-align: center;
|
||||
color: var(--m-text-muted);
|
||||
gap: 8px;
|
||||
}
|
||||
.mpd-loading > i { font-size: 1.4rem; color: var(--p-primary-color); }
|
||||
.mpd-empty__icon { font-size: 2.4rem; color: var(--m-text-faint); margin-bottom: 6px; }
|
||||
.mpd-empty__title { font-size: 0.95rem; font-weight: 600; color: var(--m-text); }
|
||||
.mpd-empty__hint { font-size: 0.82rem; max-width: 360px; line-height: 1.5; }
|
||||
.mpd-empty__actions { display: flex; gap: 8px; margin-top: 10px; }
|
||||
.mpd-link {
|
||||
background: none; border: none; color: var(--p-primary-color);
|
||||
cursor: pointer; padding: 0; font: inherit; text-decoration: underline;
|
||||
}
|
||||
|
||||
.mpd-dataview {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
.mpd-dataview :deep(.p-dataview-content) {
|
||||
flex: 1; min-height: 0; overflow-y: auto;
|
||||
background: transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.mpd-dataview :deep(.p-dataview-paginator-bottom) {
|
||||
flex-shrink: 0; background: transparent;
|
||||
border-top: 1px solid var(--m-border);
|
||||
}
|
||||
.mpd-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Drawer styles foram movidos pro <style> não-scoped abaixo —
|
||||
teleporte pro body perde o atributo data-v scoped */
|
||||
|
||||
/* ═══════ Mobile (<1024px) ═══════ */
|
||||
@media (max-width: 1023px) {
|
||||
.mpd-body {
|
||||
grid-template-columns: 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mpd-body > .mpd-side { display: none; }
|
||||
.mpd-menu-btn--mobile-only { display: inline-flex; }
|
||||
.mpd-page__title > span:first-of-type { display: none; }
|
||||
.mpd-page__title-icon { display: none; }
|
||||
.mpd-act-btn span { display: none; }
|
||||
.mpd-act-btn { width: 32px; padding: 0; justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Styles do drawer mobile NÃO-scoped — o <Teleport to="body"> tira o
|
||||
elemento da árvore do componente, então perde os atributos data-v
|
||||
do scoped. O wrapper .mpd-drawer-portal carrega .win11-root pra
|
||||
herdar os tokens --m-* (definidos em MelissaLayout). -->
|
||||
<style>
|
||||
/* Wrapper inerte — não cria stacking, só serve como host das vars */
|
||||
.mpd-drawer-portal {
|
||||
/* Garante que mesmo sem .win11-root (improvável) caia em fallbacks
|
||||
de PrimeVue tokens, que existem globalmente e respeitam dark/light. */
|
||||
--mpd-bg: var(--m-bg-medium, var(--p-content-background, #ffffff));
|
||||
--mpd-border: var(--m-border, var(--p-content-border-color, rgba(0,0,0,0.1)));
|
||||
--mpd-text: var(--m-text, var(--p-text-color, #1a1a1a));
|
||||
}
|
||||
.mpd-mobile-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
width: min(320px, 86vw);
|
||||
z-index: 80;
|
||||
background: var(--mpd-bg);
|
||||
backdrop-filter: blur(28px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||
border-right: 1px solid var(--mpd-border);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: var(--mpd-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
.mpd-mobile-drawer.is-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.mpd-mobile-drawer:not(.is-open) {
|
||||
pointer-events: none;
|
||||
}
|
||||
.mpd-mobile-drawer__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* Sidebar teleportada herda altura plena */
|
||||
.mpd-mobile-drawer__scroll .mpd-side {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--mpd-border);
|
||||
}
|
||||
.mpd-mobile-drawer__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 79;
|
||||
}
|
||||
.mpd-drawer-fade-enter-active,
|
||||
.mpd-drawer-fade-leave-active {
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
.mpd-drawer-fade-enter-from,
|
||||
.mpd-drawer-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
@@ -125,7 +125,7 @@ function onClearBg() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="glass-panel mp-panel absolute top-12 right-0 w-72">
|
||||
<div class="glass-panel mp-panel absolute bottom-12 right-0 w-72">
|
||||
<!-- Cabecalho fixo -->
|
||||
<header class="mp-head">
|
||||
<div class="mp-head__title">
|
||||
|
||||
@@ -36,7 +36,13 @@ const props = defineProps({
|
||||
filtroTipo: { type: String, default: null }
|
||||
});
|
||||
|
||||
const emit = defineEmits(['evento', 'clear-filter']);
|
||||
const emit = defineEmits(['evento', 'clear-filter', 'iniciar-cronometro']);
|
||||
|
||||
// Helper exposto no template: mostra o botao ⏱ so quando o evento esta
|
||||
// em curso E tem patient_id (atividade livre/bloqueio nao tem paciente).
|
||||
function podeIniciarCrono(ev) {
|
||||
return isEvEmCurso(ev) && !!ev?.patient_id;
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Range de horas (HORA_INICIO/HORA_FIM) — derivado de:
|
||||
@@ -382,6 +388,19 @@ onMounted(() => {
|
||||
:class="['tl-event-pill__status', statusIcon(ev)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<!-- Botao ⏱ overlay — so em sessoes em curso com
|
||||
paciente. stopPropagation pra nao disparar
|
||||
o click do pill que abre o evento. -->
|
||||
<button
|
||||
v-if="podeIniciarCrono(ev)"
|
||||
type="button"
|
||||
class="tl-event-pill__crono"
|
||||
title="Iniciar cronômetro"
|
||||
aria-label="Iniciar cronômetro"
|
||||
@click.stop="emit('iniciar-cronometro', ev)"
|
||||
>
|
||||
<i class="pi pi-stopwatch" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-0 h-full flex flex-col items-center pointer-events-none z-20"
|
||||
@@ -459,6 +478,18 @@ onMounted(() => {
|
||||
{{ fmtHora(ev.startH) }} – {{ fmtHora(ev.endH) }}
|
||||
</div>
|
||||
<div class="vt-event-label">{{ ev.label }}</div>
|
||||
<!-- Botao ⏱ overlay — so em sessoes em curso com paciente.
|
||||
stopPropagation pra nao disparar o click do pill. -->
|
||||
<button
|
||||
v-if="podeIniciarCrono(ev)"
|
||||
type="button"
|
||||
class="vt-event__crono"
|
||||
title="Iniciar cronômetro"
|
||||
aria-label="Iniciar cronômetro"
|
||||
@click.stop="emit('iniciar-cronometro', ev)"
|
||||
>
|
||||
<i class="pi pi-stopwatch" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="vt-now" :style="{ top: nowCursorTop }">
|
||||
@@ -640,6 +671,46 @@ html:not(.app-dark) .tl-day-badge--feriado {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* Botao ⏱ "Iniciar cronometro" — overlay no canto sup. direito do
|
||||
pill em sessoes em curso. Cor solida pra destacar contra o bg
|
||||
colorido do evento; pulso sutil pra chamar atencao sem irritar. */
|
||||
.tl-event-pill__crono,
|
||||
.vt-event__crono {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
padding: 0;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
||||
transition: background-color 160ms ease, transform 160ms ease, border-color 160ms ease;
|
||||
animation: tl-crono-pulse 2s ease-in-out infinite;
|
||||
z-index: 2;
|
||||
}
|
||||
.tl-event-pill__crono:hover,
|
||||
.vt-event__crono:hover {
|
||||
background: rgba(16, 185, 129, 0.85); /* emerald-500 — convida ao "play" */
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
transform: scale(1.08);
|
||||
animation-play-state: paused;
|
||||
}
|
||||
.tl-event-pill__crono > i,
|
||||
.vt-event__crono > i {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
@keyframes tl-crono-pulse {
|
||||
0%, 100% { box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35), 0 0 0 0 rgba(16, 185, 129, 0.45); }
|
||||
50% { box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35), 0 0 0 6px rgba(16, 185, 129, 0); }
|
||||
}
|
||||
|
||||
/* Realizado: glow verde sutil (cor do bg ja eh verde) — celebra o feito */
|
||||
.tl-pill--realizado {
|
||||
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.28), 0 4px 12px rgba(16, 185, 129, 0.18);
|
||||
|
||||
@@ -79,6 +79,15 @@ export const MELISSA_CONFIG_GRUPOS = [
|
||||
{ key: 'cfg-email-templates', label: 'Templates de E-mail', desc: 'Personalize os e-mails enviados aos pacientes.', icon: 'pi pi-envelope' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'documentos',
|
||||
label: 'Documentos',
|
||||
desc: 'Modelos, assinaturas e configurações da geração de documentos.',
|
||||
icon: 'pi pi-file',
|
||||
items: [
|
||||
{ key: 'documentos-templates', label: 'Modelos de documentos', desc: 'Cadastre e edite templates de recibos, atestados, laudos, TCLE, LGPD e mais.', icon: 'pi pi-file-edit' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'plataforma',
|
||||
label: 'Plataforma',
|
||||
|
||||
@@ -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
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
@@ -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' }
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -148,7 +148,7 @@ export async function loadTherapistData() {
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('full_name, phone, professional_registration_type, professional_registration_number, professional_registration_uf')
|
||||
.select('full_name, phone, professional_registration_type, professional_registration_type_other, professional_registration_number, professional_registration_uf')
|
||||
.eq('id', ownerId)
|
||||
.single();
|
||||
|
||||
@@ -156,7 +156,10 @@ export async function loadTherapistData() {
|
||||
const { data: userData } = await supabase.auth.getUser();
|
||||
const email = userData?.user?.email || '';
|
||||
|
||||
const tipo = profile?.professional_registration_type || '';
|
||||
const tipoRaw = profile?.professional_registration_type || '';
|
||||
const tipoOther = profile?.professional_registration_type_other || '';
|
||||
// Quando type='outro', usa o nome livre do conselho/instituição
|
||||
const tipo = tipoRaw === 'outro' ? tipoOther : tipoRaw;
|
||||
const numero = profile?.professional_registration_number || '';
|
||||
const uf = profile?.professional_registration_uf || '';
|
||||
const registro = formatRegistroProfissional({ tipo, numero, uf });
|
||||
@@ -173,7 +176,7 @@ export async function loadTherapistData() {
|
||||
// o número/UF (sem prefixo) pra não duplicar com o "CRP" já no HTML.
|
||||
// Quando o registro não é CRP, retorna vazio (template visualmente errado
|
||||
// pede pra usar {{terapeuta_registro}}).
|
||||
terapeuta_crp: tipo === 'CRP' ? (uf ? `${numero}/${uf}` : numero) : ''
|
||||
terapeuta_crp: tipoRaw === 'CRP' ? (uf ? `${numero}/${uf}` : numero) : ''
|
||||
};
|
||||
}
|
||||
|
||||
@@ -240,23 +243,43 @@ export async function loadAllVariables(patientId, agendaEventoId = null, extras
|
||||
const ownerId = await getOwnerId();
|
||||
const tenantId = await getActiveTenantId(ownerId);
|
||||
|
||||
const [patient, session, therapist, clinic] = await Promise.all([
|
||||
// Promise.allSettled pra nao mascarar falha individual: se uma source
|
||||
// falhar, as outras ainda preenchem e a gente loga qual quebrou.
|
||||
const results = await Promise.allSettled([
|
||||
loadPatientData(patientId),
|
||||
loadSessionData(agendaEventoId),
|
||||
loadTherapistData(),
|
||||
loadClinicData(tenantId)
|
||||
]);
|
||||
const labels = ['patient', 'session', 'therapist', 'clinic'];
|
||||
const errors = [];
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
errors.push({ source: labels[i], err: r.reason });
|
||||
console.error(`[loadAllVariables] falha em ${labels[i]}:`, r.reason);
|
||||
}
|
||||
});
|
||||
const [patient, session, therapist, clinic] = results.map(r => r.status === 'fulfilled' ? r.value : {});
|
||||
if (import.meta?.env?.DEV) {
|
||||
console.log('[loadAllVariables] resultados:', {
|
||||
patient, session, therapist, clinic,
|
||||
ownerId, tenantId, patientId, agendaEventoId,
|
||||
errors
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve valor numérico (extras tem prioridade sobre session)
|
||||
// Resolve valor numérico (extras tem prioridade sobre session).
|
||||
// Number.isFinite (strict) em vez de isFinite global: este último coerce
|
||||
// null pra 0 e retorna true, fazendo null.toFixed crashar logo abaixo.
|
||||
const valorNum = extras.valor != null
|
||||
? Number(extras.valor)
|
||||
: (session.valor ? Number(String(session.valor).replace(/[R$\s.]/g, '').replace(',', '.')) : null);
|
||||
|
||||
const valorFormatted = isFinite(valorNum)
|
||||
const valorFormatted = Number.isFinite(valorNum)
|
||||
? `R$ ${valorNum.toFixed(2).replace('.', ',')}`
|
||||
: (session.valor || '');
|
||||
|
||||
const valorExtensoStr = isFinite(valorNum) ? valorExtenso(valorNum) : '';
|
||||
const valorExtensoStr = Number.isFinite(valorNum) ? valorExtenso(valorNum) : '';
|
||||
|
||||
const merged = {
|
||||
...patient,
|
||||
@@ -381,9 +404,10 @@ export async function printDocument(template, variables = {}) {
|
||||
* @param {string} params.patientId
|
||||
* @param {object} params.dadosPreenchidos - snapshot dos dados usados
|
||||
* @param {Blob} params.pdfBlob - PDF gerado (opcional — pode ser null se so print)
|
||||
* @returns {object} registro criado
|
||||
* @param {string} [params.editingDocId] - se setado, re-edita doc existente (UPDATE)
|
||||
* @returns {object} registro criado/atualizado em document_generated
|
||||
*/
|
||||
export async function saveGeneratedDocument({ templateId, patientId, dadosPreenchidos, pdfBlob, templateNome, templateTipo }) {
|
||||
export async function saveGeneratedDocument({ templateId, patientId, dadosPreenchidos, pdfBlob, templateNome, templateTipo, editingDocId = null }) {
|
||||
const ownerId = await getOwnerId();
|
||||
const tenantId = await getActiveTenantId(ownerId);
|
||||
|
||||
@@ -405,27 +429,90 @@ export async function saveGeneratedDocument({ templateId, patientId, dadosPreenc
|
||||
if (upErr) throw upErr;
|
||||
}
|
||||
|
||||
// Registra na tabela document_generated
|
||||
const { data, error } = await supabase
|
||||
.from('document_generated')
|
||||
.insert({
|
||||
template_id: templateId,
|
||||
patient_id: patientId,
|
||||
tenant_id: tenantId,
|
||||
dados_preenchidos: dadosPreenchidos || {},
|
||||
pdf_path: pdfPath,
|
||||
storage_bucket: BUCKET,
|
||||
gerado_por: ownerId
|
||||
})
|
||||
.select('*')
|
||||
.single();
|
||||
// ─── MODO EDIT (UPDATE in-place) ─────────────────────────
|
||||
// Re-edicao: preserva documents.id (e o audit trail), substitui o PDF
|
||||
// no Storage, atualiza metadados. Best-effort cleanup do PDF antigo.
|
||||
if (editingDocId) {
|
||||
const { data: oldDoc } = await supabase
|
||||
.from('documents')
|
||||
.select('bucket_path, storage_bucket')
|
||||
.eq('id', editingDocId)
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
const docPatch = {
|
||||
tipo_documento: mapTipoDocumento(templateTipo),
|
||||
descricao: `Gerado a partir do template: ${templateNome || 'documento'}`
|
||||
};
|
||||
if (pdfPath) {
|
||||
docPatch.bucket_path = pdfPath;
|
||||
docPatch.storage_bucket = BUCKET;
|
||||
docPatch.tamanho_bytes = pdfBlob?.size || null;
|
||||
docPatch.nome_original = filename.replace(/_/g, ' ').replace('.pdf', '') + '.pdf';
|
||||
}
|
||||
const { error: upDocErr } = await supabase
|
||||
.from('documents')
|
||||
.update(docPatch)
|
||||
.eq('id', editingDocId);
|
||||
if (upDocErr) throw upDocErr;
|
||||
|
||||
// Registra na tabela documents para aparecer na lista do paciente
|
||||
// Reutiliza o mesmo arquivo do bucket generated-docs (sem upload duplicado)
|
||||
// Atualiza document_generated. Pode nao existir (docs legados sem
|
||||
// linkage) — INSERT nesse caso, com documento_id apontando pro doc.
|
||||
const { data: existingGen } = await supabase
|
||||
.from('document_generated')
|
||||
.select('id')
|
||||
.eq('documento_id', editingDocId)
|
||||
.maybeSingle();
|
||||
|
||||
let data;
|
||||
if (existingGen) {
|
||||
const genPatch = {
|
||||
template_id: templateId,
|
||||
dados_preenchidos: dadosPreenchidos || {}
|
||||
};
|
||||
if (pdfPath) genPatch.pdf_path = pdfPath;
|
||||
const { data: updated, error: upGenErr } = await supabase
|
||||
.from('document_generated')
|
||||
.update(genPatch)
|
||||
.eq('id', existingGen.id)
|
||||
.select('*')
|
||||
.single();
|
||||
if (upGenErr) throw upGenErr;
|
||||
data = updated;
|
||||
} else {
|
||||
const { data: inserted, error: insGenErr } = await supabase
|
||||
.from('document_generated')
|
||||
.insert({
|
||||
template_id: templateId,
|
||||
patient_id: patientId,
|
||||
tenant_id: tenantId,
|
||||
dados_preenchidos: dadosPreenchidos || {},
|
||||
pdf_path: pdfPath,
|
||||
storage_bucket: BUCKET,
|
||||
gerado_por: ownerId,
|
||||
documento_id: editingDocId
|
||||
})
|
||||
.select('*')
|
||||
.single();
|
||||
if (insGenErr) throw insGenErr;
|
||||
data = inserted;
|
||||
}
|
||||
|
||||
// Cleanup do PDF antigo no Storage. Falha silenciosa — arquivo orfao
|
||||
// nao quebra nada, so ocupa espaco minimo.
|
||||
if (oldDoc?.bucket_path && oldDoc.bucket_path !== pdfPath && oldDoc.storage_bucket) {
|
||||
supabase.storage.from(oldDoc.storage_bucket).remove([oldDoc.bucket_path])
|
||||
.catch((e) => console.warn('[saveGeneratedDocument] cleanup antigo falhou:', e));
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// ─── MODO CREATE (insert) ────────────────────────────────
|
||||
// Insere documents primeiro pra capturar o id e linkar em
|
||||
// document_generated via documento_id (FK).
|
||||
let documentoId = null;
|
||||
if (pdfPath) {
|
||||
await supabase
|
||||
const { data: newDoc, error: insDocErr } = await supabase
|
||||
.from('documents')
|
||||
.insert({
|
||||
owner_id: ownerId,
|
||||
@@ -442,9 +529,52 @@ export async function saveGeneratedDocument({ templateId, patientId, dadosPreenc
|
||||
visibilidade: 'privado',
|
||||
status_revisao: 'aprovado',
|
||||
uploaded_by: ownerId
|
||||
});
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
if (insDocErr) throw insDocErr;
|
||||
documentoId = newDoc?.id || null;
|
||||
}
|
||||
|
||||
// Registra em document_generated com o linkage documento_id preenchido
|
||||
const { data, error } = await supabase
|
||||
.from('document_generated')
|
||||
.insert({
|
||||
template_id: templateId,
|
||||
patient_id: patientId,
|
||||
tenant_id: tenantId,
|
||||
dados_preenchidos: dadosPreenchidos || {},
|
||||
pdf_path: pdfPath,
|
||||
storage_bucket: BUCKET,
|
||||
gerado_por: ownerId,
|
||||
documento_id: documentoId
|
||||
})
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── Buscar generated existente pra modo edit ─────────────────
|
||||
|
||||
/**
|
||||
* Busca o registro document_generated vinculado a um documents.id.
|
||||
* Retorna template_id + dados_preenchidos pra pre-popular o dialog.
|
||||
* Null se nao houver linkage (docs uploaded direto, sem template).
|
||||
*/
|
||||
export async function loadGeneratedFromDocId(documentoId) {
|
||||
if (!documentoId) return null;
|
||||
const { data, error } = await supabase
|
||||
.from('document_generated')
|
||||
.select('id, template_id, dados_preenchidos, pdf_path, gerado_em')
|
||||
.eq('documento_id', documentoId)
|
||||
.maybeSingle();
|
||||
if (error) {
|
||||
console.error('[loadGeneratedFromDocId]', error);
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
* É só 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 ────────────────────────────────────────────────────
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user