7 Commits

Author SHA1 Message Date
Leonardo fff70e4a71 saas-docs/tmp: SQL de import direto pra doc Cronometro
Script usado pra importar a doc 02-cronometro-melissa.json
diretamente no banco via psql (mesmo padrao da doc Busca global).
DO block com dollar quoting ($HTML$ e $FAQ$) pra evitar escape hell
no HTML conteudo + nos FAQs.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

MelissaPacientes: comment update mencionando o tray.

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:40:48 -03:00
13 changed files with 1076 additions and 490 deletions
+168
View File
@@ -0,0 +1,168 @@
-- Importacao da doc do Cronometro de sessao (Melissa)
-- Gerado a partir de development/saas-docs/02-cronometro-melissa.json
BEGIN;
DO $IMPORT$
DECLARE
v_doc_id uuid;
BEGIN
-- 1) Cria a doc principal
INSERT INTO public.saas_docs (
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
pagina_path, ordem, ativo, medias
) VALUES (
'Cronômetro de sessão',
$HTML$<h2>Cronômetro de sessão</h2>
<p>O <strong>cronômetro de sessão</strong> acompanha o tempo decorrido durante o atendimento e é integrado com a agenda. Quando aberto a partir de uma sessão em andamento, ele vem com o paciente pré-selecionado e dispara automaticamente.</p>
<h3>1. Três jeitos de abrir</h3>
<ul>
<li>Pelo botão <strong></strong> ao lado do relógio gigante do dashboard (abre vazio, escolha o paciente ou deixe como atividade livre)</li>
<li>Pelo botão <strong></strong> que aparece sobre os cards de sessão em curso na timeline horizontal/vertical do dashboard</li>
<li>Pelo CTA <strong>"Iniciar cronômetro"</strong> no card <em>"Próximo paciente"</em> quando a sessão está em andamento</li>
</ul>
<p>Os dois últimos pré-selecionam o paciente da sessão e disparam o timer automaticamente.</p>
<h3>2. Sessão em curso na timeline</h3>
<p>Quando uma sessão entra em andamento (horário atual entre início e fim do evento), aparece um ícone <strong></strong> pulsando no canto superior direito do card do evento. O pulso é sutil, em verde pra sinalizar que pra cronometrar dali.</p>
<div style="display: flex; align-items: center; gap: 8px; background: #6366f1; color: white; padding: 4px 10px; border-radius: 4px; max-width: 320px; position: relative; margin: 12px 0; font-family: 'Segoe UI', sans-serif;">
<span style="font-size: 0.85rem; font-weight: 600;">11:00 Larissa Almeida</span>
<span style="position: absolute; top: 3px; right: 3px; width: 22px; height: 22px; display: grid; place-items: center; background: rgba(0,0,0,0.45); border: 1px solid rgba(255,255,255,0.4); border-radius: 999px; color: white; box-shadow: 0 0 0 4px rgba(16,185,129,0.25);">
<i class="pi pi-stopwatch" style="font-size: 0.7rem;"></i>
</span>
</div>
<p>Clicar no <strong></strong> <strong>não abre o evento</strong> abre o cronômetro pré-configurado pra essa sessão.</p>
<h3>3. Programado vs tempo real</h3>
<p>Quando aberto via timeline ou card "Próximo paciente", o cronômetro mostra o <strong>horário programado original da sessão</strong> sob o select de paciente. Se você abriu depois do horário previsto, aparece um badge laranja <strong>"atrasada X min"</strong>.</p>
<div style="background: rgba(15,23,42,0.85); color: #cbd5e1; padding: 14px; border-radius: 10px; max-width: 360px; font-family: 'Segoe UI', sans-serif; margin: 12px 0;">
<label style="font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.15em; color: rgba(255,255,255,0.5); display: block; margin-bottom: 8px;">Paciente / atividade</label>
<div style="background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15); padding: 9px 14px; border-radius: 10px; font-size: 0.9rem;">Larissa Almeida</div>
<div style="display: flex; align-items: center; gap: 6px; margin-top: 8px; padding: 4px 0;">
<i class="pi pi-calendar" style="font-size: 0.7rem; color: rgba(255,255,255,0.55);"></i>
<span style="font-size: 0.78rem; color: rgba(255,255,255,0.7);">Programado: 11:00 11:50</span>
<span style="margin-left: 4px; padding: 1px 8px; border-radius: 999px; background: rgba(251,146,60,0.18); color: rgb(253,186,116); font-size: 0.7rem; font-weight: 500; border: 1px solid rgba(251,146,60,0.35);">atrasada 8 min</span>
</div>
</div>
<p> <strong>O cronômetro NÃO desconta o tempo de atraso automaticamente.</strong> Ele conta a duração configurada cheia (50min padrão) a partir do clique. A info "atrasada" é pra você decidir se quer encerrar antes ou estender.</p>
<h3>4. Anatomia do dialog</h3>
<ul>
<li><strong>Header:</strong> rótulo "Cronômetro" + status (<em>Pronto</em> / <em>Em andamento</em> / <em>Pausado</em>)</li>
<li><strong>Botão Minimizar:</strong> recolhe pro chip no dock</li>
<li><strong>Botão X (Encerrar sem salvar):</strong> descarta a sessão (com confirmação se houver atividade)</li>
<li><strong>Select de paciente:</strong> pode trocar manualmente; opção <em>"— Atividade livre"</em> pra usos sem paciente</li>
<li><strong>Programado + badge de atraso:</strong> aparece quando aberto via evento da agenda</li>
<li><strong>Display gigante mm:ss:</strong> vira vermelho quando passa do tempo planejado (mostra <code>-mm:ss</code>)</li>
<li><strong>±5 min:</strong> estende ou encurta o tempo configurado a qualquer momento</li>
<li><strong>Botão grande inferior:</strong> Começar / Parar</li>
</ul>
<h3>5. Minimizar e restaurar</h3>
<p>Click no <strong>botão minimizar</strong> (ou click fora do dialog) <strong>recolhe</strong> o cronômetro pra um <strong>chip flutuante no dock</strong> (canto inferior esquerdo, ao lado do ψ). O timer continua rodando em background. Click no chip restaura o dialog em tela cheia.</p>
<div style="display: inline-flex; align-items: center; gap: 10px; padding: 8px 14px 8px 12px; background: rgba(15,23,42,0.85); border: 1px solid rgba(255,255,255,0.18); border-radius: 999px; color: #cbd5e1; font-family: 'Segoe UI', sans-serif; box-shadow: 0 8px 24px rgba(0,0,0,0.25); margin: 12px 0;">
<i class="pi pi-stopwatch" style="font-size: 0.85rem; color: #6ee7b7;"></i>
<span style="font-variant-numeric: tabular-nums; font-weight: 500; font-size: 0.85rem;">48:13</span>
<span style="font-size: 0.72rem; color: rgba(255,255,255,0.6); padding-left: 6px; border-left: 1px solid rgba(255,255,255,0.18);">Larissa Almeida</span>
</div>
<p>Em mobile (telas &lt;768px), o chip mostra o ícone + tempo sem o nome do paciente pra caber no dock estreito. O nome continua acessível ao restaurar.</p>
<h3>6. Parar (salva) vs Fechar (descarta)</h3>
<p>Duas ações diferentes pra terminar a escolha importa:</p>
<ul>
<li><strong> Parar</strong> (botão grande inferior): encerra a contagem e <strong>SALVA o tempo decorrido no banco</strong> (evento <code>session-end</code> com elapsed em segundos). Caminho normal de fim de sessão.</li>
<li><strong>X Encerrar sem salvar</strong> (header): descarta. Pede <strong>confirmação</strong> se sessão em andamento ou tempo decorrido não fecha por acidente. Se o cronômetro está limpo (não iniciado, sem tempo), fecha direto.</li>
<li><strong>Click fora / Minimizar</strong>: NÃO encerra. Esconde o dialog e mantém o timer rodando como chip no dock.</li>
</ul>
<h3>7. Quando o tempo acaba</h3>
<p>Aos <strong>50 minutos cronometrados</strong> (ou conforme configurado), o sistema toca um <strong>som curto</strong> uma única vez. O display vira <strong>vermelho</strong> e continua contando em negativo (mostra <code>-mm:ss</code>). <strong>Não corte automático</strong> você decide quando parar.</p>
<p>O toque pode ser trocado em <strong>Configurações Cronômetro Som de término</strong>. Opções: sino, gong, soft, silêncio.</p>
<h3>8. Persistência (reload-safe)</h3>
<p>Se você fechar a aba ou recarregar o navegador com cronômetro ativo, ao voltar o sistema <strong>retoma exatamente de onde parou</strong> descontando automaticamente o tempo passado entre fechar e abrir. O snapshot fica no <code>localStorage</code> do navegador, atualizado a cada mudança de estado.</p>
<p><strong>Limite de segurança:</strong> se passar de 24h sem voltar à aba, o restore não acumula o tempo perdido (proteção contra mudanças do relógio do sistema).</p>
<h3>9. Cronômetro ativo</h3>
<p>Existe <strong>um cronômetro por vez</strong>. Se você clicar no <strong></strong> de outra sessão enquanto cronômetro rodando, o sistema mostra um toast <strong>"Cronômetro já ativo"</strong> com o nome do paciente atual <strong>e não troca</strong>. Pare o cronômetro atual antes de iniciar outro.</p>
<h3>10. Atividade livre (sem paciente)</h3>
<p>Você pode abrir o cronômetro sem paciente (botão do dashboard, sem clicar em evento específico) e selecionar <em>"— Atividade livre (sem paciente)"</em> no dropdown. Útil pra:</p>
<ul>
<li>Pausa cronometrada</li>
<li>Pomodoro pessoal</li>
<li>Atendimento informal não cadastrado</li>
</ul>
<p>Atividade livre <strong>não emite session-end</strong> ao parar (não paciente pra vincular o tempo).</p>
<h3> Notas pro desenvolvedor</h3>
<p>Atualmente o componente <code>MelissaCronometro.vue</code> <strong>não tem atributos <code>id</code></strong> em seus elementos. Para o sistema de highlight da ajuda funcionar (links <code>data-highlight</code>), sugere-se adicionar:</p>
<ul>
<li><code>id="crono-trigger-hero"</code> no botão ao lado do relógio (<code>MelissaHeroClock.vue</code>)</li>
<li><code>id="crono-trigger-timeline"</code> nos botões overlay (<code>MelissaTimelineHoje.vue</code>)</li>
<li><code>id="crono-dialog"</code> no panel principal (<code>.mc-panel</code>)</li>
<li><code>id="crono-stop-btn"</code> no botão Parar (caminho do salvamento)</li>
<li><code>id="crono-close-btn"</code> no X (caminho do descarte)</li>
</ul>$HTML$,
'Sessão',
true,
'usuario',
'/melissa',
2,
true,
'[{"tipo": "imagem", "url": ""}]'::jsonb
)
RETURNING id INTO v_doc_id;
-- 2) Insere os 12 FAQ items vinculados
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
(v_doc_id, 'Como começo o cronômetro da Larissa que chegou agora pra sessão?',
$FAQ$Quando o horário programado da Larissa estiver dentro da janela do evento ( começou na agenda), aparece um botão <strong></strong> pulsando em verde no canto superior direito do card da sessão na timeline. Clique nele o cronômetro abre com a Larissa pré-selecionada e <strong> começa a contar automaticamente</strong>. Alternativa: clique no botão <em>"Iniciar cronômetro"</em> no card "Próximo paciente" do dashboard (mesmo efeito).$FAQ$, 0, true),
(v_doc_id, 'O cronômetro continua se eu fechar a aba do navegador?',
$FAQ$<strong>Sim.</strong> O estado é salvo no <code>localStorage</code> a cada mudança (paciente, play, pause, ajustes). Ao reabrir a aba (ou recarregar), o cronômetro retoma do ponto correto o tempo passado entre fechar e abrir é descontado automaticamente. Limite: se passar de 24h, o sistema não acumula esse tempo (proteção contra mudanças do relógio do sistema).$FAQ$, 1, true),
(v_doc_id, 'Cliquei no X com sessão rodando, perdi o tempo?',
$FAQ$Não, o sistema <strong>pede confirmação antes</strong>. Quando sessão em andamento ou tempo decorrido sem salvar, aparece um diálogo <em>"Encerrar sessão sem salvar?"</em>. Você precisa clicar em <strong>"Encerrar sem salvar"</strong> (botão vermelho) pra confirmar o descarte. Se o cronômetro estiver limpo (não iniciado, sem tempo), o X fecha direto não nada pra preservar.$FAQ$, 2, true),
(v_doc_id, 'Posso ter dois cronômetros rodando ao mesmo tempo?',
$FAQ$Não. Existe <strong>um cronômetro por vez</strong>. Se você clicar no <strong></strong> de outra sessão enquanto um cronômetro ativo, o sistema mostra um toast <em>"Cronômetro já ativo — sessão de X em andamento"</em> e <strong>não troca</strong>. Pare ou descarte o atual antes de iniciar outro.$FAQ$, 3, true),
(v_doc_id, 'O que significa o badge laranja "atrasada 8 min"?',
$FAQ$Significa que <strong>o cronômetro foi aberto 8 minutos depois do horário programado</strong> da sessão na agenda. Por exemplo: sessão programada pra 11:00, você inicia o cronômetro às 11:08. O badge é apenas informativo o cronômetro continua contando a duração configurada cheia (50min padrão) a partir do clique. Você decide se vai encerrar antes pra terminar no horário previsto ou deixar rodar pra dar a sessão completa.$FAQ$, 4, true),
(v_doc_id, 'O cronômetro desconta o tempo de atraso automaticamente?',
$FAQ$<strong>Não.</strong> A decisão fica com você. Cada clínica e cada terapeuta tem uma política diferente pra atraso (alguns dão sessão cheia, outros encerram no horário programado, outros estendem). O cronômetro mostra a info do atraso pra você decidir, mas conta sempre a duração configurada cheia a partir do clique.$FAQ$, 5, true),
(v_doc_id, 'Que som toca quando o tempo acaba?',
$FAQ$Por padrão, um <strong>som de sino curto</strong>, uma única vez. Você pode trocar em <strong>Configurações Cronômetro Som de término</strong>. Opções: sino, gong, soft, silêncio. O som toca <strong>exatamente na transição</strong> de tempo positivo pra zero/negativo não repete. Depois disso o display continua contando em negativo (vermelho) até você parar.$FAQ$, 6, true),
(v_doc_id, 'Como adiciono mais tempo na sessão sem reiniciar?',
$FAQ$Use os botões <strong>+5 min</strong> e <strong>-5 min</strong> ao redor do display gigante. Funcionam a qualquer momento antes, durante ou depois do tempo acabar. Cada clique soma ou desconta 5 minutos. Se o tempo está negativo (passou do limite), +5min volta a contagem pra positivo.$FAQ$, 7, true),
(v_doc_id, 'Onde fica salvo o tempo final da sessão?',
$FAQ$Quando você clica em <strong> Parar</strong>, o tempo cronometrado é gravado no banco vinculado à sessão da agenda (na tabela <code>agenda_eventos</code>, campo de duração real). Esse caminho é o oficial <strong>fechar pelo X descarta sem salvar</strong>. Sessões com menos de 5 segundos cronometrados são ignoradas (proteção contra start/stop acidentais).$FAQ$, 8, true),
(v_doc_id, 'Posso usar o cronômetro pra coisas que não são sessão de paciente?',
$FAQ$Sim. Selecione <em>"— Atividade livre (sem paciente)"</em> no dropdown de paciente. Útil pra pausa cronometrada, pomodoro pessoal, atendimento informal não cadastrado. Atividade livre <strong>não dispara session-end</strong> ao parar não paciente pra vincular o tempo no DB.$FAQ$, 9, true),
(v_doc_id, 'Como minimizo o cronômetro pra continuar trabalhando?',
$FAQ$Clique no botão <strong>minimizar</strong> no header (ícone <code></code>) ou simplesmente <strong>clique fora do dialog</strong>. O cronômetro vira um chip flutuante no dock (canto inferior esquerdo, ao lado do ψ) e continua contando em background. Pra restaurar: clique no chip. Em mobile, o chip mostra ícone + tempo (sem nome) pra caber no dock estreito.$FAQ$, 10, true),
(v_doc_id, 'Como mudo o paciente no cronômetro já aberto?',
$FAQ$Basta clicar no <strong>select de paciente</strong> e escolher outro. A troca é imediata não reinicia o tempo decorrido (a contagem continua igual). Útil quando você abriu o cronômetro no paciente errado e quer corrigir sem perder o tempo contado. Mas atenção: o <em>session-end</em> ao parar vai vincular o tempo ao paciente que estiver selecionado <em>no momento da parada</em>.$FAQ$, 11, true);
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
END;
$IMPORT$;
COMMIT;
File diff suppressed because one or more lines are too long
+19 -1
View File
@@ -15,7 +15,7 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed } from 'vue';
import { ref, computed, watch, onBeforeUnmount } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAjuda } from '@/composables/useAjuda';
@@ -73,6 +73,24 @@ function fechar() {
faqAbertos.value = {};
closeDrawer();
}
// ── Fechar ao clicar fora ─────────────────────────────────────
// Listener so existe enquanto o drawer esta aberto. Clique nos botoes
// que abrem/fecham o drawer (marcados com data-ajuda-toggle) sao
// ignorados — senao fecha aqui e o @click reabre.
function onDocMouseDown(e) {
if (!drawerOpen.value) return;
const t = e.target;
if (!(t instanceof Element)) return;
if (t.closest('.ajuda-panel')) return; // dentro do drawer
if (t.closest('[data-ajuda-toggle]')) return; // botao trigger
closeDrawer();
}
watch(drawerOpen, (open) => {
if (open) document.addEventListener('mousedown', onDocMouseDown, true);
else document.removeEventListener('mousedown', onDocMouseDown, true);
});
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocMouseDown, true));
// ── Highlight de elemento na página ──────────────────────────
async function handleDocClick(e) {
const anchor = e.target.closest('a[data-highlight]');
+3 -2
View File
@@ -620,8 +620,9 @@ onMounted(async () => {
<NotificationDrawer />
</div>
<!-- Ajuda -->
<button type="button" class="rail-topbar__btn" :class="{ 'rail-topbar__btn--active': ajudaDrawerOpen }" :title="ajudaDrawerOpen ? 'Fechar ajuda' : 'Ajuda'" @click="toggleAjuda">
<!-- Ajuda data-ajuda-toggle ignora este botao no
click-outside do AjudaDrawer (senao fecha + reabre). -->
<button type="button" class="rail-topbar__btn" :class="{ 'rail-topbar__btn--active': ajudaDrawerOpen }" :title="ajudaDrawerOpen ? 'Fechar ajuda' : 'Ajuda'" data-ajuda-toggle @click="toggleAjuda">
<i class="pi pi-question-circle" />
</button>
+22 -50
View File
@@ -31,7 +31,6 @@ import ProximosFeriadosCard from '@/features/agenda/components/ProximosFeriadosC
import MelissaAgendaHistoricoCard from './MelissaAgendaHistoricoCard.vue';
import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue';
import Popover from 'primevue/popover';
import MelissaAgendaSearchPopover from './MelissaAgendaSearchPopover.vue';
import MelissaAgendaActionsPopover from './MelissaAgendaActionsPopover.vue';
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
@@ -691,29 +690,15 @@ const fcOptions = computed(() => ({
}));
// ── Busca da toolbar (datas + paciente/título) ────────────────
// Componente MelissaAgendaSearchPopover (autocontido) gerencia o Cmd+K
// search inteiro — input, debounce, parsing de datas, resultados.
// Aqui só ficam o ref pro toggle (botão da toolbar + hotkey global) e
// os 2 handlers que decidem o que fazer com a escolha (gotoDate +
// auto-select de paciente quando há patient_id no evento).
const searchPopover = ref(null);
// Busca agora vive na MelissaBusca (global, Ctrl+K em qualquer tela ou
// botao na .melissa-tray). Aqui fica so a fn gotoDate exposta pro
// MelissaLayout chamar quando o usuario escolhe "Ir para [data]" na
// busca global — vide defineExpose mais abaixo.
function onBuscaGotoDate(date) {
fcApi()?.gotoDate(date);
refDate.value = new Date(date);
}
function onBuscaSelectEvento(ev) {
if (!ev?.inicio_em) return;
fcApi()?.gotoDate(ev.inicio_em);
refDate.value = new Date(ev.inicio_em);
// Auto-seleciona o paciente se o evento tiver um — assim a agenda já
// fica filtrada por ele e o dock contextual aparece.
if (ev.patient_id) {
pacienteSelecionadoId.value = ev.patient_id;
}
}
// Card de histórico (audit_logs) — ref pra disparar refetch após
// mutações; handler que abre o evento clicado pelo id.
const historicoCardRef = ref(null);
@@ -760,18 +745,8 @@ async function onHistoricoOpen({ id }) {
}
}
// Atalho global Ctrl/Cmd+K abre a busca via ref do popover.
function _onSearchHotkey(e) {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
// Anchor virtual no botão da toolbar — necessário pra Popover do
// PrimeVue posicionar corretamente.
const btn = document.querySelector('.ma-cal__search-btn');
if (btn) searchPopover.value?.toggle({ currentTarget: btn, target: btn });
}
}
onMounted(() => { window.addEventListener('keydown', _onSearchHotkey); });
onBeforeUnmount(() => { window.removeEventListener('keydown', _onSearchHotkey); });
// Ctrl+K e' tratado pela propria MelissaBusca (listener global no
// window) — removido o handler local pra nao disparar 2 vezes.
// Toolbar — atalhos pra FC API
function fcApi() {
@@ -1321,12 +1296,20 @@ function openProntuario(patient) {
if (!patient?.id) return;
abrirProntuarioPorId(patient.id);
}
// gotoDate exposto pro MelissaLayout chamar quando o usuario escolhe
// "Ir para [data]" na MelissaBusca (busca global). Reusa onBuscaGotoDate
// que ja atualiza fcApi + refDate.
function gotoDateExternal(date) {
onBuscaGotoDate(date);
}
defineExpose({
refetch: refetchEventosFc,
openProntuario,
setView,
openSessoesPaciente,
openEditPatient
openEditPatient,
gotoDate: gotoDateExternal
});
</script>
@@ -1629,21 +1612,10 @@ defineExpose({
/>
</div>
<!-- Busca sempre visível. Abre popover com input + lista de
resultados. Suporta data (20/04, hoje) e texto (paciente/
título). Ctrl/Cmd+K abre via hotkey global. -->
<button
class="ma-cal__icon ma-cal__search-btn w-7 h-7 grid place-items-center bg-transparent border-0 text-[var(--m-text)] rounded-md cursor-pointer text-[0.78rem] transition-colors duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] focus-visible:outline-2 focus-visible:outline-[var(--m-accent)] focus-visible:outline-offset-2"
v-tooltip.top="'Buscar (Ctrl+K)'"
@click="searchPopover?.toggle($event)"
>
<i class="pi pi-search" />
</button>
<MelissaAgendaSearchPopover
ref="searchPopover"
@goto-date="onBuscaGotoDate"
@select-evento="onBuscaSelectEvento"
/>
<!-- Busca migrou pra .melissa-tray (sempre visivel).
Ctrl+K em qualquer tela abre o mesmo spotlight,
que ja entende data (20/04, hoje, amanha) e
paciente/sessao via RPC search_global. -->
<!-- Bloquear: ícone-only com Menu popup. Visível só
em ≥xl. Em <xl vai pra dentro de "Ações". -->
@@ -2051,9 +2023,9 @@ defineExpose({
to { opacity: 1; transform: scale(1); }
}
/* .ma-tsearch* migrou inteiro pra MelissaAgendaSearchPopover.vue (componente
autocontido). Cmd+K hotkey global continua aqui no parent — chama
`searchPopover.value?.toggle(...)` apontando pro botao .ma-cal__search-btn. */
/* Busca da agenda migrou inteira pra MelissaBusca (componente global,
no MelissaLayout). Acesso por Ctrl+K em qualquer tela ou pela lupa na
.melissa-tray (canto inferior direito). Toolbar nao tem mais botao. */
/* .ma-actions* migrou inteiro pra MelissaAgendaActionsPopover.vue
(componente autocontido, com utilities Tailwind no template). */
@@ -1,327 +0,0 @@
<script setup>
/*
* MelissaAgendaSearchPopover — busca da toolbar da Agenda Melissa
* --------------------------------------------------------------
* Pattern Cmd+K: input + lista de resultados num Popover. Suporta:
* - Datas: "20/04", "20/04/2026", "hoje", "amanhã", "ontem"
* → emit('goto-date', date) — pai navega o FullCalendar
* - Texto livre: pesquisa server-side em patients.nome_completo + titulo
* (via searchEventosByText) com debounce de 300ms. Limite 20 resultados.
* → emit('select-evento', ev) — pai aciona gotoDate + auto-select patient
*
* Componente autocontido — owns todo o state, debounce, parsing.
* Pai expõe um botão âncora (`.ma-cal__search-btn`) e chama
* `popoverRef.value.toggle($event)` no click. Hotkey Cmd+K também
* vive no pai (acha o botão via querySelector e chama toggle).
*
* Emit:
* - goto-date(date: Date) — escolheu uma data do parser
* - select-evento(ev) — escolheu um evento da lista de busca
*
* Exposto via defineExpose:
* - toggle(event) — abre/fecha o popover, foca input quando abre
*/
import { ref, onBeforeUnmount } from 'vue';
import Popover from 'primevue/popover';
import { searchEventosByText } from './composables/useMelissaEventos';
const emit = defineEmits(['goto-date', 'select-evento']);
const popRef = ref(null);
const inputRef = ref(null);
const searchQuery = ref('');
const searchResults = ref([]);
const searchLoading = ref(false);
const searchDateMatch = ref(null); // Date | null — preenchido se query parsear como data
let _debounceTimer = null;
// Token monotonico — protege contra race condition: se o user digita "ab"
// e depois "abc", o request "ab" pode resolver DEPOIS do "abc" em conexao
// lenta. Cada request guarda seu token; ao voltar, so aplica se ainda eh
// o mais recente. Cancelamento via AbortController seria ideal mas exigiria
// searchEventosByText aceitar signal — por ora token resolve sem mexer no API.
let _searchToken = 0;
function parseSearchAsDate(str) {
const t = String(str || '').trim().toLowerCase();
if (!t) return null;
if (t === 'hoje') { const d = new Date(); d.setHours(0,0,0,0); return d; }
if (t === 'amanha' || t === 'amanhã') {
const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() + 1); return d;
}
if (t === 'ontem') {
const d = new Date(); d.setHours(0,0,0,0); d.setDate(d.getDate() - 1); return d;
}
// DD/MM ou DD/MM/YYYY (também aceita - e .)
const m = t.match(/^(\d{1,2})[/\-.](\d{1,2})(?:[/\-.](\d{2,4}))?$/);
if (m) {
const day = parseInt(m[1], 10);
const month = parseInt(m[2], 10);
let year = parseInt(m[3] || '', 10);
if (!year || Number.isNaN(year)) year = new Date().getFullYear();
if (year < 100) year += 2000;
if (day < 1 || day > 31 || month < 1 || month > 12) return null;
const d = new Date(year, month - 1, day);
if (Number.isNaN(d.getTime())) return null;
return d;
}
return null;
}
function toggle(event) {
popRef.value?.toggle(event);
// Foco no input quando abrir (Popover anima ~150ms). PrimeVue InputText
// renderiza `<input>` direto, então `$el` já é o elemento focável.
// Tentamos algumas vezes pra cobrir mount async + transição do Popover.
let tries = 0;
const tick = () => {
const el = inputRef.value?.$el;
if (el && typeof el.focus === 'function') { el.focus(); el.select?.(); return; }
if (tries++ < 8) setTimeout(tick, 30);
};
setTimeout(tick, 80);
}
function fechar() {
try { popRef.value?.hide(); } catch {}
}
// Toda vez que o popover fecha (via ESC, click-fora, ou submit/select),
// reseta o state pra proxima abertura comecar limpa. Antes, ESC mantinha
// query+resultados "fantasmas" — UX confusa: reabrir mostrava busca
// antiga que talvez nao fizesse mais sentido.
function onPopoverHide() {
searchQuery.value = '';
searchResults.value = [];
searchDateMatch.value = null;
searchLoading.value = false;
++_searchToken; // invalida requests em flight
}
function onSearchInput() {
if (_debounceTimer) clearTimeout(_debounceTimer);
const q = searchQuery.value;
searchDateMatch.value = parseSearchAsDate(q);
// Se digitou data, mostra resultado imediato (sem hit no DB)
if (searchDateMatch.value) {
// Invalida tokens em flight pra eles nao voltarem e sobrescreverem [].
++_searchToken;
searchResults.value = [];
searchLoading.value = false;
return;
}
if (String(q || '').trim().length < 2) {
++_searchToken;
searchResults.value = [];
searchLoading.value = false;
return;
}
searchLoading.value = true;
_debounceTimer = setTimeout(async () => {
const myToken = ++_searchToken;
try {
const results = await searchEventosByText(q);
// Race guard: se outro request foi disparado depois deste,
// descarta este (out-of-order resolution).
if (myToken !== _searchToken) return;
searchResults.value = results;
} finally {
// So zera loading se este eh o ultimo request — senao deixa o
// proximo controlar o estado (evita flicker de loading=false
// entre requests sequenciais rapidos).
if (myToken === _searchToken) searchLoading.value = false;
}
}, 300);
}
function onSearchSubmit() {
// Enter: prioriza data, senão pega o primeiro resultado
if (searchDateMatch.value) {
irParaData(searchDateMatch.value);
return;
}
if (searchResults.value.length > 0) {
selecionarResultado(searchResults.value[0]);
}
}
function irParaData(date) {
emit('goto-date', date);
fechar(); // @hide handler limpa state
}
function selecionarResultado(ev) {
if (!ev?.inicio_em) return;
emit('select-evento', ev);
fechar(); // @hide handler limpa state
}
function fmtDataResultado(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', weekday: 'short' });
}
function fmtHoraResultado(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
onBeforeUnmount(() => {
if (_debounceTimer) clearTimeout(_debounceTimer);
});
defineExpose({ toggle, close: fechar });
</script>
<template>
<Popover ref="popRef" class="ma-tsearch-pop" @hide="onPopoverHide">
<div class="ma-tsearch flex flex-col w-[min(440px,calc(100vw-32px))] max-h-[500px] overflow-hidden">
<div class="ma-tsearch__field relative flex items-center gap-2 px-2.5 py-1.5 mx-2 mt-2 mb-1.5 bg-[var(--m-bg-soft)] border border-[var(--m-border)] rounded-[10px] flex-shrink-0 transition-[border-color,background-color] duration-[140ms] focus-within:border-[var(--p-primary-color)] focus-within:bg-[var(--m-bg-soft-hover)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--p-primary-color)_12%,transparent)]">
<i class="pi pi-search ma-tsearch__field-icon text-[var(--m-text-muted)] text-[0.85rem] flex-shrink-0" />
<InputText
ref="inputRef"
v-model="searchQuery"
placeholder="Data (20/04) ou nome do paciente…"
class="ma-tsearch__input"
@input="onSearchInput"
@keydown.enter="onSearchSubmit"
@keydown.esc="fechar"
/>
<button
v-if="searchQuery"
class="ma-tsearch__clear w-[22px] h-[22px] grid place-items-center border-0 bg-[var(--m-bg-medium)] text-[var(--m-text-muted)] rounded-full cursor-pointer flex-shrink-0 transition-colors duration-[140ms] hover:bg-[var(--m-border-strong)] hover:text-[var(--m-text)]"
v-tooltip.top="'Limpar'"
@click="searchQuery = ''; onSearchInput()"
>
<i class="pi pi-times text-xs" />
</button>
</div>
<!-- Resultado data (sem hit no DB) -->
<button
v-if="searchDateMatch"
class="ma-tsearch__result ma-tsearch__result--date w-auto self-stretch flex items-center gap-2.5 px-2.5 py-2 mx-2 mb-1.5 rounded-lg cursor-pointer text-left [font-family:inherit] transition-colors duration-[140ms]"
@click="irParaData(searchDateMatch)"
>
<span class="ma-tsearch__result-icon w-8 h-8 grid place-items-center rounded-lg flex-shrink-0 text-[0.78rem]"><i class="pi pi-calendar" /></span>
<span class="ma-tsearch__result-main flex-1 min-w-0 flex flex-col gap-0.5">
<span class="ma-tsearch__result-title text-[0.85rem] font-medium whitespace-nowrap overflow-hidden text-ellipsis capitalize">Ir para {{ searchDateMatch.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', weekday: 'long' }) }}</span>
<span class="ma-tsearch__result-sub text-[0.72rem] text-[var(--m-text-muted)] whitespace-nowrap overflow-hidden text-ellipsis">Pular para essa data no calendário</span>
</span>
<i class="pi pi-arrow-right text-xs opacity-50" />
</button>
<!-- Loading -->
<div v-else-if="searchLoading" class="ma-tsearch__loading flex items-center gap-2 px-3.5 py-[18px] text-[var(--m-text-muted)] text-[0.85rem]">
<i class="pi pi-spin pi-spinner" /> <span>Buscando</span>
</div>
<!-- Resultados (eventos) -->
<div v-else-if="searchResults.length > 0" class="ma-tsearch__results flex-1 min-h-0 overflow-y-auto p-1">
<button
v-for="ev in searchResults"
:key="ev.id"
class="ma-tsearch__result w-full flex items-center gap-2.5 px-2.5 py-2 border-0 bg-transparent text-[var(--m-text)] rounded-lg cursor-pointer text-left [font-family:inherit] transition-colors duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] focus-visible:bg-[var(--m-bg-soft-hover)] focus-visible:outline-none"
@click="selecionarResultado(ev)"
>
<span class="ma-tsearch__result-icon w-8 h-8 grid place-items-center rounded-lg text-white flex-shrink-0 text-[0.78rem]" :style="{ background: ev.color }">
<i :class="ev.tipo === 'sessao' ? 'pi pi-user' : 'pi pi-calendar'" />
</span>
<span class="ma-tsearch__result-main flex-1 min-w-0 flex flex-col gap-0.5">
<span class="ma-tsearch__result-title text-[0.85rem] font-medium text-[var(--m-text)] whitespace-nowrap overflow-hidden text-ellipsis">{{ ev.label }}</span>
<span class="ma-tsearch__result-sub text-[0.72rem] text-[var(--m-text-muted)] whitespace-nowrap overflow-hidden text-ellipsis">
{{ fmtDataResultado(ev.inicio_em) }} · {{ fmtHoraResultado(ev.inicio_em) }}
<span v-if="ev.modalidade"> · {{ ev.modalidade }}</span>
</span>
</span>
</button>
</div>
<!-- Vazio ( buscou mas nada encontrou) -->
<div v-else-if="String(searchQuery || '').trim().length >= 2" class="ma-tsearch__empty flex flex-col items-center gap-2 px-4 py-[26px] mx-2 mb-2.5 mt-1 text-[var(--m-text-muted)] text-[0.82rem] border-[1.5px] border-dashed border-[color-mix(in_srgb,var(--p-primary-color)_22%,var(--m-border))] rounded-[14px] bg-[color-mix(in_srgb,var(--m-bg-soft)_50%,transparent)] text-center [&>i]:text-[2.2rem] [&>i]:opacity-70 [&>i]:text-[var(--p-primary-color)] [&>i]:mb-0.5">
<i class="pi pi-search-minus" />
<span class="ma-tsearch__empty-title text-[0.88rem] font-semibold text-[var(--m-text)]">Busca não encontrada</span>
<span class="ma-tsearch__empty-sub text-[0.78rem] text-[var(--m-text-muted)] leading-[1.35] [&_strong]:text-[var(--m-text)] [&_strong]:font-semibold">
Nada para "<strong>{{ searchQuery }}</strong>"
</span>
</div>
<!-- Hint inicial Message PrimeVue (auto-resolve via PrimeVueResolver) -->
<Message
v-else
severity="info"
:closable="false"
icon="pi pi-info-circle"
class="ma-tsearch__hint mx-2 mb-2.5"
>
Digite uma data (<strong>20/04</strong>, <strong>hoje</strong>, <strong>amanhã</strong>) ou
o nome do paciente (<strong>André</strong>).
</Message>
</div>
</Popover>
</template>
<style scoped>
/* Estilos que NAO migram pra utilities Tailwind:
- .ma-tsearch__input.p-inputtext: override do PrimeVue InputText pra ele
viver dentro do "field box" (zera bordas, bg, shadow padrao). Selector
composto com classe externa do PrimeVue.
- .ma-tsearch__hint :deep(.p-message-text): override de typography dentro
do componente filho Message (isolado por scope).
- .ma-tsearch__result--date: a versao "Ir para data" tem cores azul fixas
(rgb diretas, sem var()) com !important pra blindar contra hover do
base .ma-tsearch__result. State modifier mais limpo em CSS. */
.ma-tsearch__input.p-inputtext {
flex: 1;
border: none;
background: transparent;
outline: none;
box-shadow: none;
padding: 8px 0;
font-size: 0.9rem;
color: var(--m-text);
min-width: 0;
}
.ma-tsearch__input.p-inputtext:enabled:focus,
.ma-tsearch__input.p-inputtext:enabled:hover {
box-shadow: none;
border-color: transparent;
background: transparent;
}
.ma-tsearch__input.p-inputtext::placeholder { color: var(--m-text-muted); }
.ma-tsearch__hint :deep(.p-message-text) {
font-size: 0.82rem;
line-height: 1.4;
}
.ma-tsearch__hint strong { font-weight: 600; }
/* "Ir para data" — destaque azul claro independente da primary do tenant.
Cores diretas (sem color-mix com bg-soft) pra garantir contraste em
dark mode onde --m-bg-soft tem alpha 50% e diluiria o tint demais.
!important pra blindar contra hover do .ma-tsearch__result base. */
.ma-tsearch__result--date {
background: rgba(59, 130, 246, 0.16) !important;
border: 1.5px solid rgba(59, 130, 246, 0.55) !important;
}
.ma-tsearch__result--date:hover,
.ma-tsearch__result--date:focus-visible {
background: rgba(59, 130, 246, 0.26) !important;
border-color: rgba(59, 130, 246, 0.75) !important;
}
.ma-tsearch__result--date .ma-tsearch__result-icon {
background: #3b82f6 !important;
color: white !important;
}
.ma-tsearch__result--date .ma-tsearch__result-title {
color: #2563eb !important; /* blue-600 — boa leitura em ambos os modos */
}
/* Dark mode — clareia o título pra contrastar com o fundo escuro */
html.app-dark .ma-tsearch__result--date .ma-tsearch__result-title {
color: #93c5fd !important; /* blue-300 */
}
html.app-dark .ma-tsearch__result--date .ma-tsearch__result-sub {
color: rgba(147, 197, 253, 0.7) !important;
}
</style>
+115 -3
View File
@@ -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 ( 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;
+175 -7
View File
@@ -5,7 +5,8 @@
* Cronômetro de sessão estilo "janela do Windows":
* - Dialog centralizado com select de paciente, display gigante e ações
* - Click fora minimiza (chip no canto superior esquerdo)
* - X/ESC fecha (destrói)
* - X = encerrar sem salvar. Com confirmacao se houver sessao em
* andamento ou tempo decorrido (fechar limpo nao pede confirm)
* - Botão inferior alterna entre "▶ Começar" e "⏹ Parar"
* - "+1 minuto" estende o tempo
* - Quando minimizado, o timer continua rodando em background
@@ -23,8 +24,11 @@
* cronoRef.value.fechar() // destrói
*/
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { useConfirm } from 'primevue/useconfirm';
import { playToque } from './melissaToques';
const confirm = useConfirm();
const STORAGE_KEY = 'melissa.cronometro.v1';
const props = defineProps({
@@ -57,6 +61,16 @@ const seconds = ref(props.duracaoMinutos * 60);
const pacienteId = ref(props.defaultPacienteId);
let timer = null;
// Plano da sessao (horario programado original do evento na agenda).
// Setado quando o cronometro abre a partir de um evento da timeline
// vide abrir({ sessionPlan: { startH, endH } }). Null quando aberto
// manualmente. Persiste em localStorage junto com o resto do snap.
const sessionPlan = ref(null); // { startH: number, endH: number } | null
// Tick a cada 30s pra recomputar atraso conforme o tempo passa (so
// quando cronometro existe; reseta no fechar).
const _planNowTick = ref(Date.now());
let _planNowTimer = null;
// True só durante a transição de minimizar (dialog chip).
// Permite trocar a animação leave do dialog por "shrink + fly to dock"
// (tipo macOS minimize) em vez do lift normal usado pra fechar.
@@ -90,21 +104,86 @@ const pacienteNome = computed(() => {
return p ? p.nome : '';
});
// Formatador hh:mm a partir do startH decimal (ex: 11.5 "11:30").
function _fmtHora(h) {
if (typeof h !== 'number' || Number.isNaN(h)) return '';
const hh = Math.floor(h);
const mm = Math.round((h - hh) * 60);
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
}
const sessionPlanLabel = computed(() => {
const p = sessionPlan.value;
if (!p || typeof p.startH !== 'number' || typeof p.endH !== 'number') return '';
return `Programado: ${_fmtHora(p.startH)} ${_fmtHora(p.endH)}`;
});
// Atraso em minutos vs horario programado. Calcula em relacao ao
// _planNowTick (atualizado a cada 30s) pra nao recomputar a cada frame.
const atrasoMin = computed(() => {
const p = sessionPlan.value;
if (!p || typeof p.startH !== 'number') return 0;
// ref usada so pra forcar recompute periodico
void _planNowTick.value;
const d = new Date();
const hNow = d.getHours() + d.getMinutes() / 60;
const diff = hNow - p.startH;
if (diff <= 0) return 0;
return Math.round(diff * 60);
});
// Watch: avisa parent quando dialog aparece/some
watch(visible, (v) => emit('visible-change', v));
// Ações
function abrir() {
// abrir({ pacienteId, autostart }) opts permitem pre-selecionar
// um paciente (ex: click no botao "iniciar sessao" da timeline) e
// auto-iniciar a contagem. Sem opts mantem comportamento legado.
// Retorna { opened, alreadyRunning, pacienteId } pra caller decidir
// (ex: mostrar toast se ja tem cronometro rodando de outro paciente).
function abrir(opts = {}) {
const requestedPid = Object.prototype.hasOwnProperty.call(opts, 'pacienteId')
? opts.pacienteId
: props.defaultPacienteId;
if (exists.value) {
// Já existe apenas restaura se tava minimizado (não cria outro)
// Ja existe comportamento opcao (b): nao troca paciente. Apenas
// restaura visualmente se estava minimizado. Caller decide se
// mostra toast quando o paciente requisitado e' diferente do atual.
if (minimized.value) minimized.value = false;
return;
return {
opened: false,
alreadyRunning: !!running.value,
pacienteId: pacienteId.value,
samePaciente: pacienteId.value === requestedPid
};
}
seconds.value = props.duracaoMinutos * 60;
pacienteId.value = props.defaultPacienteId;
pacienteId.value = requestedPid;
running.value = false;
minimized.value = false;
exists.value = true;
// Plano programado (vem do evento da timeline) usado pra exibir
// "Programado: HH:MM HH:MM" e badge de atraso. Sanitiza pra
// garantir 2 numeros validos; senao limpa.
const plan = opts.sessionPlan;
if (plan && typeof plan.startH === 'number' && typeof plan.endH === 'number'
&& !Number.isNaN(plan.startH) && !Number.isNaN(plan.endH)) {
sessionPlan.value = { startH: plan.startH, endH: plan.endH };
} else {
sessionPlan.value = null;
}
if (opts.autostart) {
// Defer pra rodar dps do mount/render do dialog (toggle precisa
// do setInterval em proximo tick pra contar a partir do segundo
// cheio, nao perde a fracao do tick atual).
setTimeout(() => { if (exists.value && !running.value) toggle(); }, 0);
}
return {
opened: true,
alreadyRunning: false,
pacienteId: pacienteId.value,
samePaciente: true
};
}
function toggle() {
@@ -161,9 +240,33 @@ function fechar() {
running.value = false;
minimized.value = false;
exists.value = false;
sessionPlan.value = null; // limpa pra proxima abertura comecar zerada
emit('close');
}
// Fechar com confirmacao quando ha sessao em andamento ou tempo
// decorrido sem salvar. Estado "clean" (parado + nada decorrido)
// fecha direto pra nao atrapalhar quem abriu por engano.
function confirmarFechar() {
const totalInicial = props.duracaoMinutos * 60;
const temAtividade = running.value || seconds.value !== totalInicial;
if (!temAtividade) {
fechar();
return;
}
confirm.require({
message: running.value
? 'Sessão em andamento — encerrar agora descarta o tempo cronometrado sem salvar no DB.'
: 'O cronômetro tem tempo decorrido que ainda não foi salvo. Quer descartar?',
header: 'Encerrar sessão sem salvar?',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Encerrar sem salvar',
rejectLabel: 'Continuar sessão',
acceptClass: 'p-button-danger',
accept: () => fechar()
});
}
function ajustarMinutos(delta) {
seconds.value += delta * 60;
// Re-baselina savedAt pra restore após reload pegar o novo valor correto
@@ -185,7 +288,8 @@ function saveState() {
minimized: !!minimized.value,
running: !!running.value,
seconds: seconds.value,
savedAt: Date.now()
savedAt: Date.now(),
sessionPlan: sessionPlan.value // null | { startH, endH }
};
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(snap)); } catch {}
}
@@ -223,6 +327,15 @@ function loadState() {
seconds.value = restoredSeconds;
exists.value = true;
running.value = false; // toggle abaixo flipa pra true
// Restaura plano programado se foi serializado. Sanitiza shape:
// {startH:number, endH:number} valido ou null.
const sp = snap.sessionPlan;
if (sp && typeof sp.startH === 'number' && typeof sp.endH === 'number'
&& !Number.isNaN(sp.startH) && !Number.isNaN(sp.endH)) {
sessionPlan.value = { startH: sp.startH, endH: sp.endH };
} else {
sessionPlan.value = null;
}
if (wasRunning) {
// Retoma o interval. NÃO toca o toque retroativo se o tempo
@@ -235,6 +348,19 @@ function loadState() {
// Watch nas mudanças de estado discreto (não em seconds enquanto roda savedAt+delta dá conta)
watch([exists, minimized, running, pacienteId], () => saveState());
// Tick a cada 30s pra recomputar atraso. So roda quando o cronometro
// existe E tem plano programado senao desperdicio.
watch([exists, sessionPlan], ([e, p]) => {
if (e && p) {
if (!_planNowTimer) {
_planNowTimer = setInterval(() => { _planNowTick.value = Date.now(); }, 30_000);
}
} else if (_planNowTimer) {
clearInterval(_planNowTimer);
_planNowTimer = null;
}
});
// Mount / Cleanup
onMounted(() => {
loadState();
@@ -242,6 +368,7 @@ onMounted(() => {
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
if (_planNowTimer) clearInterval(_planNowTimer);
});
defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible });
@@ -264,7 +391,7 @@ defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible });
<button class="mc-glass-btn" title="Minimizar" @click="minimizar">
<i class="pi pi-window-minimize text-white/90 text-xs" />
</button>
<button class="mc-glass-btn" title="Fechar" @click="fechar">
<button class="mc-glass-btn" title="Encerrar sem salvar" @click="confirmarFechar">
<i class="pi pi-times text-white/90 text-sm" />
</button>
</div>
@@ -284,6 +411,17 @@ defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible });
</select>
<i class="pi pi-chevron-down mc-select-icon" />
</div>
<!-- Plano programado da sessao (so quando aberto via
evento da timeline). Mostra horario original +
badge de atraso se aplicavel analista decide,
cronometro nao auto-ajusta. -->
<div v-if="sessionPlanLabel" class="mc-session-plan">
<i class="pi pi-calendar text-white/55 text-[0.7rem]" />
<span class="text-white/70 text-[0.78rem]">{{ sessionPlanLabel }}</span>
<span v-if="atrasoMin > 0" class="mc-session-plan__late">
atrasada {{ atrasoMin }} min
</span>
</div>
</div>
<!-- Display gigante + steppers manuais (+5 / -5) -->
@@ -436,6 +574,28 @@ defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible });
pointer-events: none;
}
/* Plano programado linha abaixo do select com horario original e
badge de atraso quando aplicavel. Tom secundario pra nao roubar
atencao do display gigante do cronometro. */
.mc-session-plan {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 4px 0;
}
.mc-session-plan__late {
margin-left: 4px;
padding: 1px 8px;
border-radius: 9999px;
background: rgba(251, 146, 60, 0.18);
color: rgb(253, 186, 116);
font-size: 0.7rem;
font-weight: 500;
line-height: 1.4;
border: 1px solid rgba(251, 146, 60, 0.35);
}
/* ─── Display gigante ──────────────────────────────────────── */
.mc-display {
font-size: 5rem;
@@ -567,6 +727,14 @@ defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible });
padding-left: 6px;
border-left: 1px solid var(--m-border-strong);
}
/* Em mobile (<md=768px) o chip vive num dock estreito que precisa
acomodar 4 builtins + ψ + tray. Esconde o nome do paciente o
icone + timer ja sinalizam o estado, e o nome continua disponivel
ao restaurar o cronometro (click). */
@media (max-width: 767px) {
.mc-chip-name { display: none; }
.mc-chip { padding: 8px 12px; }
}
.mc-chip-pulse {
animation: mc-pulse 1.6s ease-in-out infinite;
}
+8 -1
View File
@@ -72,7 +72,7 @@ const emit = defineEmits(['cronometro', 'toggle-filtro']);
class="resumo-link"
:class="{ 'is-active': filtroTipo === p.tipo }"
@click="emit('toggle-filtro', p.tipo)"
>{{ p.text }}</button><span v-if="i < resumoPartes.length - 2">,&nbsp;</span><span v-else-if="i === resumoPartes.length - 2">&nbsp;e&nbsp;</span>
>{{ p.text }}</button><span v-if="p.suffix" class="resumo-suffix">{{ p.suffix }}</span><span v-if="i < resumoPartes.length - 2">,&nbsp;</span><span v-else-if="i === resumoPartes.length - 2">&nbsp;e&nbsp;</span>
</template>.
</template>
</span>
@@ -135,6 +135,13 @@ const emit = defineEmits(['cronometro', 'toggle-filtro']);
border-bottom: 1px solid var(--m-text);
}
/* Sufixo "(1 foi cancelado, 2 foram remarcados)" texto secundario,
nao clicavel, com peso menor pra nao competir com o chip. */
.resumo-suffix {
color: var(--m-text-muted, rgba(255, 255, 255, 0.6));
font-size: 0.9em;
}
/* Modo "fundo nos textos soltos"
Toggle no painel Personalizar. Cada bloco de texto (.hero-text)
ganha um fundo solido translucido + borda + padding pra ficar
+401 -95
View File
@@ -575,9 +575,50 @@ function setPreset(name) {
//
// Settings popover (canto superior direito)
// Settings popover (canto inferior direito vive na .melissa-tray)
//
const settingsOpen = ref(false);
const cogBtnEl = ref(null);
// "More" tray popup visivel so em mobile (<md). Collapse de bell/
// help/cog/plan-DEV num menu vertical pra economizar largura.
const trayMoreOpen = ref(false);
const trayMoreBtnEl = ref(null);
// Fechar ao clicar fora: listener so existe enquanto o popover esta
// aberto. mousedown (capture) fecha antes do click chegar mas o
// proprio cog precisa ser ignorado, senao fecha aqui e o @click do
// botao re-abre na sequencia.
function onSettingsDocMouseDown(e) {
if (!settingsOpen.value) return;
const t = e.target;
if (!(t instanceof Element)) return;
if (t.closest('.mp-panel')) return; // clique dentro do panel
if (cogBtnEl.value?.contains(t)) return; // clique no proprio cog
settingsOpen.value = false;
}
watch(settingsOpen, (open) => {
if (open) document.addEventListener('mousedown', onSettingsDocMouseDown, true);
else document.removeEventListener('mousedown', onSettingsDocMouseDown, true);
});
onBeforeUnmount(() => document.removeEventListener('mousedown', onSettingsDocMouseDown, true));
// Mesmo padrao pro popup "More" do tray em mobile: ignora o proprio
// botao trigger (senao fecha + reabre no click) e fecha em qualquer
// outro lugar fora do panel.
function onTrayMoreDocMouseDown(e) {
if (!trayMoreOpen.value) return;
const t = e.target;
if (!(t instanceof Element)) return;
if (t.closest('.melissa-tray__more-panel')) return;
if (trayMoreBtnEl.value?.contains(t)) return;
trayMoreOpen.value = false;
}
watch(trayMoreOpen, (open) => {
if (open) document.addEventListener('mousedown', onTrayMoreDocMouseDown, true);
else document.removeEventListener('mousedown', onTrayMoreDocMouseDown, true);
});
onBeforeUnmount(() => document.removeEventListener('mousedown', onTrayMoreDocMouseDown, true));
//
// Timeline horizontal range/eco/posicoes/auto-scroll/cursor "Agora"
@@ -587,10 +628,19 @@ const settingsOpen = ref(false);
// Pai so passa eventos brutos + workRules/settings/feriados via props,
// e recebe @evento (pra abrir dialog) + @clear-filter (pra limpar tipo).
// Contagens por tipo + frase resumo do dia
// Contagens por tipo + frase resumo do dia. Pra sessao tambem quebro
// por status (cancelado/remarcado) pra montar o sufixo "(x foi cancelado,
// x foi remarcado)" depois do chip de atendimentos.
const contagensDia = computed(() => {
const c = { sessao: 0, supervisao: 0, reuniao: 0 };
for (const ev of eventosHojeReais.value) c[ev.tipo] = (c[ev.tipo] || 0) + 1;
const c = { sessao: 0, supervisao: 0, reuniao: 0, sessaoCancelada: 0, sessaoRemarcada: 0 };
for (const ev of eventosHojeReais.value) {
c[ev.tipo] = (c[ev.tipo] || 0) + 1;
if (ev.tipo === 'sessao') {
const s = String(ev.status || '').toLowerCase();
if (s === 'cancelado' || s === 'cancelada') c.sessaoCancelada += 1;
else if (s === 'remarcado') c.sessaoRemarcada += 1;
}
}
return c;
});
@@ -598,11 +648,34 @@ function pluralizar(n, singular, plural) {
return `${n} ${n === 1 ? singular : plural}`;
}
// Sufixo "(1 foi cancelado, 2 foram remarcados)" depois do chip de
// atendimentos quando houver sessoes cancel/remarcado no dia.
function _statusSuffix(qtdCancel, qtdRemarc) {
const partes = [];
if (qtdCancel > 0) {
partes.push(qtdCancel === 1
? `${qtdCancel} foi cancelado`
: `${qtdCancel} foram cancelados`);
}
if (qtdRemarc > 0) {
partes.push(qtdRemarc === 1
? `${qtdRemarc} foi remarcado`
: `${qtdRemarc} foram remarcados`);
}
return partes.length ? ` (${partes.join(', ')})` : '';
}
// Partes estruturadas pro template renderizar cada contagem como link clicável
const resumoPartes = computed(() => {
const c = contagensDia.value;
const partes = [];
if (c.sessao > 0) partes.push({ tipo: 'sessao', text: pluralizar(c.sessao, 'atendimento', 'atendimentos') });
if (c.sessao > 0) {
partes.push({
tipo: 'sessao',
text: pluralizar(c.sessao, 'atendimento', 'atendimentos'),
suffix: _statusSuffix(c.sessaoCancelada, c.sessaoRemarcada)
});
}
if (c.supervisao > 0) partes.push({ tipo: 'supervisao', text: pluralizar(c.supervisao, 'supervisão', 'supervisões') });
if (c.reuniao > 0) partes.push({ tipo: 'reuniao', text: pluralizar(c.reuniao, 'reunião', 'reuniões') });
return partes;
@@ -1745,6 +1818,22 @@ function _callOnAgenda(action) {
if (secaoAberta.value !== 'agenda') abrirSecao('agenda');
}
// MelissaBusca @goto-date usuario digitou "hoje"/"20/06" na busca
// global. Abre a agenda se fechada (via _callOnAgenda que enfileira a
// action ate o ref aparecer) e chama gotoDate exposto pela MelissaAgenda.
function onBuscaGotoDate(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return;
_callOnAgenda((agenda) => agenda.gotoDate?.(date));
}
// Ref + provide pra qualquer secao filha pedir pra abrir a busca global
// programaticamente. UI nao tem mais botao por secao (lupa unica fica
// na .melissa-tray), mas o inject permanece exposto pra acoes contextuais
// futuras (ex: "buscar paciente" num componente filho que quer abrir
// o spotlight com query pre-preenchida).
const melissaBuscaRef = ref(null);
provide('openMelissaBusca', () => melissaBuscaRef.value?.openDialog?.());
function onAbrirProntuario() {
const ev = eventoSelecionado.value;
if (!ev?.patient_id) {
@@ -1987,6 +2076,30 @@ const { toqueTermino, testarToque } = useMelissaToques('sino');
function abrirCronometro() {
cronoRef.value?.abrir();
}
// Click no botao de um evento da timeline (ou no CTA do card
// "Proximo paciente" quando o evento esta em curso). Pre-seleciona
// o paciente + autostart. Se ja houver cronometro rodando de outro
// paciente, mostra toast sem trocar (opcao b decidida 2026-05-22).
function onIniciarCronometroFromEvento(ev) {
if (!ev?.patient_id) return;
// Plano programado: horario original do evento na agenda. So passa se
// os campos forem numericos validos abrir() sanitiza de novo internamente.
const sessionPlan = (typeof ev.startH === 'number' && typeof ev.endH === 'number')
? { startH: ev.startH, endH: ev.endH }
: null;
const ret = cronoRef.value?.abrir({ pacienteId: ev.patient_id, autostart: true, sessionPlan });
if (ret && !ret.opened && ret.alreadyRunning && !ret.samePaciente) {
const atualNome = pacientesReais.value.find((p) => String(p.id) === String(ret.pacienteId))?.nome
|| 'outro paciente';
toast.add({
severity: 'warn',
summary: 'Cronômetro já ativo',
detail: `Sessão de ${atualNome} em andamento. Pare o cronômetro atual antes de iniciar outro.`,
life: 3500
});
}
}
function fecharCronometro() {
cronoRef.value?.fechar();
}
@@ -2347,71 +2460,6 @@ function onKeydown(e) {
<!-- PLANO DE TRÁS Resumo (recebe blur quando workspace abre) -->
<!-- -->
<div class="win11-summary" :class="{ 'is-behind': summaryDimmed }">
<!-- Faixa de fundo do topbar gradiente horizontal
(cor solida na direita -> transparente na esquerda)
pra dar legibilidade aos icones sem virar barra solida.
Cor flipa com light/dark via --m-band. -->
<div class="melissa-topbar-band" aria-hidden="true"></div>
<!-- Topbar Melissa (canto sup. direito): plan-DEV + notificações
+ ajuda + cog. Os 3 primeiros vêm do AppTopbar replicados
aqui porque a rota /melissa é fullscreen e não monta o
AppLayout. Drawer de notificações também é montado abaixo
(AjudaDrawer é global no App.vue). -->
<div class="absolute top-5 right-5 z-30 flex items-center gap-2">
<!-- Plan switcher DEV ( aparece em dev / com flag) -->
<button
v-if="showPlanDevMenu"
ref="planBtn"
class="glass-btn w-10 h-10 grid place-items-center"
:disabled="planMenuLoading || trocandoPlano"
title="Plano (DEV)"
@click="openPlanMenu"
>
<i v-if="planMenuLoading || trocandoPlano" class="pi pi-spin pi-spinner text-white/90 text-base" />
<i v-else class="pi pi-sliders-h text-white/90 text-base" />
</button>
<!-- Notificações -->
<button
class="glass-btn w-10 h-10 grid place-items-center relative"
title="Notificações"
@click="notificationStore.drawerOpen = true"
>
<i class="pi pi-bell text-white/90 text-base" />
<span
v-if="notificationStore.unreadCount > 0"
class="m-topbar-badge"
>{{ notificationStore.unreadCount > 99 ? '99+' : notificationStore.unreadCount }}</span>
</button>
<!-- Ajuda -->
<button
class="glass-btn w-10 h-10 grid place-items-center"
:class="{ 'glass-btn--active': ajudaDrawerOpen }"
:title="ajudaDrawerOpen ? 'Fechar ajuda' : 'Ajuda'"
@click="toggleAjuda"
>
<i class="pi pi-question-circle text-white/90 text-base" />
</button>
<!-- Cog (settings popover) existente -->
<button
class="glass-btn w-10 h-10 grid place-items-center"
title="Personalizar"
@click="settingsOpen = !settingsOpen"
>
<i class="pi pi-cog text-white/90 text-base" />
</button>
<Transition name="settings-pop">
<MelissaSettingsPanel
v-if="settingsOpen"
@close="settingsOpen = false"
/>
</Transition>
</div>
<!-- Conteúdo central -->
<div class="win11-summary__inner">
<!-- Bloco hero: relógio + data + saudação + resumo do dia -->
@@ -2428,6 +2476,7 @@ function onKeydown(e) {
<!-- Busca rápida (entre o "Hoje há" e a timeline) -->
<MelissaBusca
ref="melissaBuscaRef"
class="mt-8"
:pacientes="pacientesReais"
:eventos="eventosHojeReais"
@@ -2436,6 +2485,7 @@ function onKeydown(e) {
@evento="abrirEvento"
@documento="(d) => d?.patient_id ? router.push({ path: '/melissa/paciente', query: { id: String(d.patient_id), tab: 'documentos' } }) : abrirSecao('pacientes')"
@intake="() => abrirSecao('cadastros-recebidos')"
@goto-date="onBuscaGotoDate"
/>
<!-- Timeline horizontal + vertical (responsivo) -->
@@ -2448,6 +2498,7 @@ function onKeydown(e) {
:filtro-tipo="filtroTipo"
@evento="abrirEvento"
@clear-filter="limparFiltro"
@iniciar-cronometro="onIniciarCronometroFromEvento"
/>
<!-- Cards (catálogo + ativos + layout switchável) -->
@@ -2460,14 +2511,24 @@ function onKeydown(e) {
:class="cardsLayout === 'linha-unica' ? 'cards-row--linha' : 'cards-row--wrap'"
>
<template v-for="cardId in cardsAtivos" :key="cardId">
<!-- Próximo paciente -->
<!-- Próximo paciente. Se o evento esta em curso E tem
paciente, action vira "Iniciar cronômetro" pra
facilitar o fluxo "estou comecando a sessao agora". -->
<MelissaCard
v-if="cardId === 'proximo-paciente'"
icon="pi pi-user"
icon-color="text-emerald-300"
title="Próximo paciente"
:action-title="proximoPaciente ? 'Abrir sessão' : 'Abrir Pacientes'"
@open="proximoPaciente ? abrirEvento(proximoPaciente.ev) : abrirSecao('pacientes')"
:action-title="proximoPaciente
? (proximoPaciente.emCurso && proximoPaciente.ev?.patient_id
? 'Iniciar cronômetro'
: 'Abrir sessão')
: 'Abrir Pacientes'"
@open="proximoPaciente
? (proximoPaciente.emCurso && proximoPaciente.ev?.patient_id
? onIniciarCronometroFromEvento(proximoPaciente.ev)
: abrirEvento(proximoPaciente.ev))
: abrirSecao('pacientes')"
>
<div v-if="proximoPaciente" class="flex items-center gap-3">
<div
@@ -2613,14 +2674,163 @@ function onKeydown(e) {
<span class="psi-kbd" aria-hidden="true">Ctrl \</span>
</button>
<!-- -->
<!-- TRAY Melissa (system tray win11-style, canto inf. direito) -->
<!-- busca + plan-DEV + notificações + ajuda + cog. Sibling de -->
<!-- .dock (fora de .win11-summary) pra ficar sempre interativo,-->
<!-- mesmo com secao aberta (que aplica blur+pointer-none na -->
<!-- summary). AjudaDrawer ja e global no App.vue. -->
<!-- -->
<div class="melissa-tray">
<!-- Busca global (Ctrl+K) afordancia visivel pra mouse/touch.
Centraliza o ponto de acesso entre seccoes (em vez de
cada toolbar ter o proprio botao). -->
<button
class="glass-btn w-10 h-10 grid place-items-center"
v-tooltip.top="'Buscar (Ctrl+K)'"
aria-label="Busca global"
@click="melissaBuscaRef?.openDialog?.()"
>
<i class="pi pi-search text-white/90 text-base" />
</button>
<!-- Plan switcher DEV ( aparece em dev / com flag).
Em mobile (<md) some vai pro popup do "more". -->
<button
v-if="showPlanDevMenu"
ref="planBtn"
class="glass-btn w-10 h-10 hidden md:grid place-items-center"
:disabled="planMenuLoading || trocandoPlano"
v-tooltip.top="'Plano (DEV)'"
@click="openPlanMenu"
>
<i v-if="planMenuLoading || trocandoPlano" class="pi pi-spin pi-spinner text-white/90 text-base" />
<i v-else class="pi pi-sliders-h text-white/90 text-base" />
</button>
<!-- Notificações em mobile (<md) some, vai pro popup. -->
<button
class="glass-btn w-10 h-10 hidden md:grid place-items-center relative"
v-tooltip.top="'Notificações'"
@click="notificationStore.drawerOpen = true"
>
<i class="pi pi-bell text-white/90 text-base" />
<span
v-if="notificationStore.unreadCount > 0"
class="m-topbar-badge"
>{{ notificationStore.unreadCount > 99 ? '99+' : notificationStore.unreadCount }}</span>
</button>
<!-- Ajuda (conteudo muda com a rota via AjudaDrawer global).
data-ajuda-toggle: marca pro AjudaDrawer ignorar este botao
na deteccao de "clicou fora pra fechar" (senao fecha aqui
e o @click reabre). Em mobile (<md) some, vai pro popup. -->
<button
class="glass-btn w-10 h-10 hidden md:grid place-items-center"
:class="{ 'glass-btn--active': ajudaDrawerOpen }"
v-tooltip.top="ajudaDrawerOpen ? 'Fechar ajuda' : 'Ajuda'"
data-ajuda-toggle
@click="toggleAjuda"
>
<i class="pi pi-question-circle text-white/90 text-base" />
</button>
<!-- Cog (settings popover abre pra CIMA vide MelissaSettingsPanel).
Em mobile (<md) some, vai pro popup. -->
<button
ref="cogBtnEl"
class="glass-btn w-10 h-10 hidden md:grid place-items-center"
v-tooltip.top="'Personalizar'"
@click="settingsOpen = !settingsOpen"
>
<i class="pi pi-cog text-white/90 text-base" />
</button>
<Transition name="settings-pop">
<MelissaSettingsPanel
v-if="settingsOpen"
@close="settingsOpen = false"
/>
</Transition>
<!-- "More" so em mobile (<md). Collapse de plan-DEV+bell+help+
cog num popup vertical. Dot vermelho aparece se houver
notificacoes nao-lidas (preserva o sinal visual da bell). -->
<button
ref="trayMoreBtnEl"
class="glass-btn w-10 h-10 grid md:hidden place-items-center relative"
:class="{ 'glass-btn--active': trayMoreOpen }"
v-tooltip.top="trayMoreOpen ? 'Fechar' : 'Mais'"
aria-label="Mais opcoes"
@click="trayMoreOpen = !trayMoreOpen"
>
<i class="pi pi-ellipsis-v text-white/90 text-base" />
<span
v-if="notificationStore.unreadCount > 0"
class="melissa-tray__more-dot"
aria-hidden="true"
/>
</button>
<Transition name="settings-pop">
<div v-if="trayMoreOpen" class="melissa-tray__more-panel glass-panel">
<!-- Notificacoes -->
<button
class="melissa-tray__more-item"
@click="notificationStore.drawerOpen = true; trayMoreOpen = false"
>
<i class="pi pi-bell" />
<span class="flex-1 text-left">Notificações</span>
<span
v-if="notificationStore.unreadCount > 0"
class="m-topbar-badge"
style="position: static; transform: none;"
>{{ notificationStore.unreadCount > 99 ? '99+' : notificationStore.unreadCount }}</span>
</button>
<!-- Ajuda -->
<button
class="melissa-tray__more-item"
:class="{ 'is-active': ajudaDrawerOpen }"
data-ajuda-toggle
@click="toggleAjuda(); trayMoreOpen = false"
>
<i class="pi pi-question-circle" />
<span class="flex-1 text-left">{{ ajudaDrawerOpen ? 'Fechar ajuda' : 'Ajuda' }}</span>
</button>
<!-- Personalizar (cog) -->
<button
class="melissa-tray__more-item"
@click="settingsOpen = !settingsOpen; trayMoreOpen = false"
>
<i class="pi pi-cog" />
<span class="flex-1 text-left">Personalizar</span>
</button>
<!-- Plano DEV (so se flag) -->
<button
v-if="showPlanDevMenu"
class="melissa-tray__more-item"
:disabled="planMenuLoading || trocandoPlano"
@click="openPlanMenu($event); trayMoreOpen = false"
>
<i :class="planMenuLoading || trocandoPlano ? 'pi pi-spin pi-spinner' : 'pi pi-sliders-h'" />
<span class="flex-1 text-left">Plano (DEV)</span>
</button>
</div>
</Transition>
</div>
<!-- -->
<!-- DOCK (taskbar Win11-style sem chrome) receptáculo pra -->
<!-- elementos minimizados (cronômetro, futuros) + atalhos -->
<!-- pinned (Agenda, WhatsApp). Transparent, os items são -->
<!-- clicáveis. ψ vive ao lado (absolute, bottom-left). -->
<!-- pinned (Agenda, Pacientes, WhatsApp, Financeiro). Transp.-->
<!-- os items sao clicaveis. ψ vive ao lado (absolute, -->
<!-- bottom-left). -->
<!-- -->
<div class="melissa-dock">
<!-- Pinned: atalhos diretos pras seções mais usadas.
<!-- Pinned builtins: 4 atalhos pras secoes principais.
Tamanho menor que ψ (44px vs 56px) + cantos arredondados
mas não full-circle, pra hierarquia visual ficar óbvia. -->
<button
@@ -2632,6 +2842,15 @@ function onKeydown(e) {
>
<i class="pi pi-calendar" />
</button>
<button
type="button"
class="dock-pin"
v-tooltip.top="'Pacientes'"
:class="{ 'dock-pin--active': secaoAberta === 'pacientes' }"
@click="abrirSecao('pacientes')"
>
<i class="pi pi-users" />
</button>
<button
type="button"
class="dock-pin"
@@ -2646,12 +2865,24 @@ function onKeydown(e) {
:title="`${whatsappPendente.count} mensagens não lidas`"
>{{ whatsappPendente.count > 99 ? '99+' : whatsappPendente.count }}</span>
</button>
<button
type="button"
class="dock-pin"
v-tooltip.top="'Financeiro'"
:class="{ 'dock-pin--active': secaoAberta === 'financeiro' }"
@click="abrirSecao('financeiro')"
>
<i class="pi pi-wallet" />
</button>
<!-- Divisor entre builtins e pins dinâmicos. aparece se
o user tem pelo menos 1 pin (fixo ou recente). -->
o user tem pelo menos 1 pin (fixo ou recente).
Em mobile (<md), se so houver MRU (que e oculto), o
divisor tambem some pra nao "soltar" no fim do dock. -->
<div
v-if="dockPins.pinned.value.length || dockPins.recent.value.length"
class="dock-divider"
:class="{ 'hidden md:block': !dockPins.pinned.value.length }"
aria-hidden="true"
/>
@@ -2671,7 +2902,11 @@ function onKeydown(e) {
</button>
<!-- Pins MRU (max 3) empurrados pelas últimas seções abertas.
Visual mais leve (opacity menor) pra destacar dos fixos. -->
Visual mais leve (opacity menor) pra destacar dos fixos.
Em mobile (<md=768px) sao ocultos via media query no CSS
do .dock-pin--recent utility 'hidden' do Tailwind perde
pro 'display: grid' base do .dock-pin (mesma specificity,
ordem de carga). -->
<button
v-for="slug in dockPins.recent.value" :key="`r-${slug}`"
type="button"
@@ -3856,7 +4091,7 @@ function onKeydown(e) {
.settings-pop-enter-from,
.settings-pop-leave-to {
opacity: 0;
transform: translateY(-6px) scale(0.98);
transform: translateY(6px) scale(0.98);
}
/* line-clamp util (caso Tailwind não tenha) */
@@ -4011,24 +4246,88 @@ function onKeydown(e) {
--m-hero-text-border: rgba(255, 255, 255, 0.12);
}
/* Faixa de fundo do topbar (canto sup. direito)
Gradiente horizontal: cor solida na direita (onde os icones vivem)
e fade pra transparente na esquerda. z-index abaixo do topbar
(z-30) e acima do conteudo principal. */
.melissa-topbar-band {
/* Tray Melissa (system tray win11-style, canto inferior direito)
Posiciona o grupo de icones globais (plan-DEV, notificacoes, ajuda,
cog) alinhado verticalmente com os pins do dock. z-index acima do
dock (65) pra ficar no mesmo plano interativo. */
.melissa-tray {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 80px;
z-index: 25;
bottom: 0;
right: 1.25rem;
height: var(--m-dock-h, 76px);
z-index: 66;
display: flex;
align-items: center;
gap: 8px;
}
/* Dot vermelho no botao "More" da tray (mobile) sinaliza que ha
notificacoes nao-lidas escondidas dentro do popup. */
.melissa-tray__more-dot {
position: absolute;
top: 6px;
right: 6px;
width: 8px;
height: 8px;
border-radius: 9999px;
background: rgb(239, 68, 68);
border: 1.5px solid var(--m-bg-medium, rgba(0, 0, 0, 0.4));
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
pointer-events: none;
background: linear-gradient(
to left,
var(--m-band) 0%,
var(--m-band) 25%,
transparent 75%
);
}
/* Popup vertical do "More" abre acima do botao trigger, mesmo padrao
visual do MelissaSettingsPanel mas mais compacto. */
.melissa-tray__more-panel {
position: absolute;
bottom: calc(var(--m-dock-h, 76px) - 8px);
right: 0;
min-width: 220px;
padding: 6px;
display: flex;
flex-direction: column;
gap: 2px;
border-radius: 14px;
z-index: 67;
}
.melissa-tray__more-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 12px;
background: transparent;
border: none;
color: var(--m-text);
border-radius: 8px;
cursor: pointer;
font-family: inherit;
font-size: 0.88rem;
text-align: left;
transition: background-color 140ms ease, color 140ms ease;
}
.melissa-tray__more-item:hover:not(:disabled),
.melissa-tray__more-item:focus-visible {
background: var(--m-bg-soft-hover);
outline: none;
}
.melissa-tray__more-item.is-active {
background: var(--m-bg-soft-hover);
color: white;
}
.melissa-tray__more-item:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.melissa-tray__more-item > i {
font-size: 0.95rem;
width: 16px;
color: var(--m-text-muted);
flex-shrink: 0;
}
.melissa-tray__more-item:hover > i,
.melissa-tray__more-item.is-active > i {
color: var(--m-text);
}
/* Dock (global pra atravessar Teleport + evitar perda de scoped
@@ -4165,6 +4464,13 @@ html:not(.app-dark) .dock-divider {
.dock-pin--recent.dock-pin--active {
opacity: 1;
}
/* Em mobile (<md=768px) os pins MRU somem pra economizar largura
(4 builtins + user-fixed pins ja saturam o dock). Media query no
bloco do .dock-pin--recent ganha do utility 'hidden' do Tailwind
por ordem de carga (mesma specificity). */
@media (max-width: 767px) {
.dock-pin--recent { display: none; }
}
/* Skeleton loading utilitário (global pra atravessar scoped CSS dos
componentes filhos). Use a classe .melissa-skeleton em qualquer
+3
View File
@@ -313,6 +313,9 @@ async function refetchTudo() {
}
// Estado de UI
// Busca local filtra a lista visivel combinada com filtros de
// status/grupo/tag. Busca global (Ctrl+K) tem botao dedicado no
// .melissa-tray, fora desta seccao.
const busca = ref('');
const statusFiltro = ref('ativos'); // 'todos' | 'ativos' | 'inativos' | 'arquivados'
const grupoFiltroId = ref(null); // null = todos
+1 -1
View File
@@ -125,7 +125,7 @@ function onClearBg() {
</script>
<template>
<div class="glass-panel mp-panel absolute top-12 right-0 w-72">
<div class="glass-panel mp-panel absolute bottom-12 right-0 w-72">
<!-- Cabecalho fixo -->
<header class="mp-head">
<div class="mp-head__title">
+72 -1
View File
@@ -36,7 +36,13 @@ const props = defineProps({
filtroTipo: { type: String, default: null }
});
const emit = defineEmits(['evento', 'clear-filter']);
const emit = defineEmits(['evento', 'clear-filter', 'iniciar-cronometro']);
// Helper exposto no template: mostra o botao so quando o evento esta
// em curso E tem patient_id (atividade livre/bloqueio nao tem paciente).
function podeIniciarCrono(ev) {
return isEvEmCurso(ev) && !!ev?.patient_id;
}
//
// Range de horas (HORA_INICIO/HORA_FIM) derivado de:
@@ -382,6 +388,19 @@ onMounted(() => {
:class="['tl-event-pill__status', statusIcon(ev)]"
aria-hidden="true"
/>
<!-- Botao overlay so em sessoes em curso com
paciente. stopPropagation pra nao disparar
o click do pill que abre o evento. -->
<button
v-if="podeIniciarCrono(ev)"
type="button"
class="tl-event-pill__crono"
title="Iniciar cronômetro"
aria-label="Iniciar cronômetro"
@click.stop="emit('iniciar-cronometro', ev)"
>
<i class="pi pi-stopwatch" />
</button>
</div>
<div
class="absolute top-0 h-full flex flex-col items-center pointer-events-none z-20"
@@ -459,6 +478,18 @@ onMounted(() => {
{{ fmtHora(ev.startH) }} {{ fmtHora(ev.endH) }}
</div>
<div class="vt-event-label">{{ ev.label }}</div>
<!-- Botao overlay so em sessoes em curso com paciente.
stopPropagation pra nao disparar o click do pill. -->
<button
v-if="podeIniciarCrono(ev)"
type="button"
class="vt-event__crono"
title="Iniciar cronômetro"
aria-label="Iniciar cronômetro"
@click.stop="emit('iniciar-cronometro', ev)"
>
<i class="pi pi-stopwatch" />
</button>
</div>
<div class="vt-now" :style="{ top: nowCursorTop }">
@@ -640,6 +671,46 @@ html:not(.app-dark) .tl-day-badge--feriado {
margin-left: 0;
}
/* Botao "Iniciar cronometro" overlay no canto sup. direito do
pill em sessoes em curso. Cor solida pra destacar contra o bg
colorido do evento; pulso sutil pra chamar atencao sem irritar. */
.tl-event-pill__crono,
.vt-event__crono {
position: absolute;
top: 3px;
right: 3px;
width: 22px;
height: 22px;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.45);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 9999px;
cursor: pointer;
font-family: inherit;
padding: 0;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
transition: background-color 160ms ease, transform 160ms ease, border-color 160ms ease;
animation: tl-crono-pulse 2s ease-in-out infinite;
z-index: 2;
}
.tl-event-pill__crono:hover,
.vt-event__crono:hover {
background: rgba(16, 185, 129, 0.85); /* emerald-500 — convida ao "play" */
border-color: rgba(255, 255, 255, 0.6);
transform: scale(1.08);
animation-play-state: paused;
}
.tl-event-pill__crono > i,
.vt-event__crono > i {
font-size: 0.7rem;
}
@keyframes tl-crono-pulse {
0%, 100% { box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35), 0 0 0 0 rgba(16, 185, 129, 0.45); }
50% { box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35), 0 0 0 6px rgba(16, 185, 129, 0); }
}
/* Realizado: glow verde sutil (cor do bg ja eh verde) — celebra o feito */
.tl-pill--realizado {
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.28), 0 4px 12px rgba(16, 185, 129, 0.18);