Compare commits
15 Commits
c17c547ed2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b0b636c660 | |||
| 701d9f4fcc | |||
| 34412c6883 | |||
| 3a42b0696d | |||
| 7dd8cde8b4 | |||
| ec56f9429b | |||
| 89bf181742 | |||
| 342defecde | |||
| fff70e4a71 | |||
| 550c4ade44 | |||
| 473e0f026e | |||
| 9f3a047d6d | |||
| 8bf992910d | |||
| fa2b431a56 | |||
| eb42759979 |
@@ -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:
|
||||
|
||||
@@ -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 @@
|
||||
-- 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
@@ -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]');
|
||||
|
||||
@@ -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) {
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -109,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) {
|
||||
@@ -153,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>
|
||||
@@ -299,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"
|
||||
|
||||
@@ -99,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;
|
||||
@@ -119,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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -122,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 }));
|
||||
@@ -139,7 +189,8 @@ 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);
|
||||
@@ -176,9 +227,17 @@ function onKeydown(e) {
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
activeIndex.value = Math.max(activeIndex.value - 1, 0);
|
||||
} else if (e.key === 'Enter' && activeIndex.value >= 0) {
|
||||
} 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)
|
||||
}
|
||||
@@ -206,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 () => {
|
||||
@@ -241,6 +308,12 @@ onBeforeUnmount(() => {
|
||||
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>
|
||||
@@ -291,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>
|
||||
@@ -660,6 +752,26 @@ onBeforeUnmount(() => {
|
||||
: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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -57,6 +57,10 @@ 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
|
||||
@@ -123,9 +127,15 @@ async function onPreview(doc) {
|
||||
function onDownload(doc) { download(doc); }
|
||||
|
||||
function onEdit(doc) {
|
||||
selectedDoc.value = doc;
|
||||
// Reusa preview dialog em modo "ver" — edit completo só via DocumentsListPage
|
||||
onPreview(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) {
|
||||
@@ -380,13 +390,24 @@ onBeforeUnmount(() => {
|
||||
: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"
|
||||
v-model:visible="generateDlg"
|
||||
: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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -404,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);
|
||||
|
||||
@@ -428,8 +429,57 @@ export async function saveGeneratedDocument({ templateId, patientId, dadosPreenc
|
||||
if (upErr) throw upErr;
|
||||
}
|
||||
|
||||
// Registra na tabela document_generated
|
||||
const { data, error } = await supabase
|
||||
// ─── 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();
|
||||
|
||||
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;
|
||||
|
||||
// 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,
|
||||
@@ -438,17 +488,31 @@ export async function saveGeneratedDocument({ templateId, patientId, dadosPreenc
|
||||
dados_preenchidos: dadosPreenchidos || {},
|
||||
pdf_path: pdfPath,
|
||||
storage_bucket: BUCKET,
|
||||
gerado_por: ownerId
|
||||
gerado_por: ownerId,
|
||||
documento_id: editingDocId
|
||||
})
|
||||
.select('*')
|
||||
.single();
|
||||
if (insGenErr) throw insGenErr;
|
||||
data = inserted;
|
||||
}
|
||||
|
||||
if (error) throw error;
|
||||
// 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));
|
||||
}
|
||||
|
||||
// Registra na tabela documents para aparecer na lista do paciente
|
||||
// Reutiliza o mesmo arquivo do bucket generated-docs (sem upload duplicado)
|
||||
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,
|
||||
@@ -465,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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user