Compare commits
7 Commits
c17c547ed2
...
fff70e4a71
| Author | SHA1 | Date | |
|---|---|---|---|
| fff70e4a71 | |||
| 550c4ade44 | |||
| 473e0f026e | |||
| 9f3a047d6d | |||
| 8bf992910d | |||
| fa2b431a56 | |||
| eb42759979 |
@@ -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;
|
||||||
File diff suppressed because one or more lines are too long
@@ -15,7 +15,7 @@
|
|||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
-->
|
-->
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed, watch, onBeforeUnmount } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useAjuda } from '@/composables/useAjuda';
|
import { useAjuda } from '@/composables/useAjuda';
|
||||||
|
|
||||||
@@ -73,6 +73,24 @@ function fechar() {
|
|||||||
faqAbertos.value = {};
|
faqAbertos.value = {};
|
||||||
closeDrawer();
|
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 ──────────────────────────
|
// ── Highlight de elemento na página ──────────────────────────
|
||||||
async function handleDocClick(e) {
|
async function handleDocClick(e) {
|
||||||
const anchor = e.target.closest('a[data-highlight]');
|
const anchor = e.target.closest('a[data-highlight]');
|
||||||
|
|||||||
@@ -620,8 +620,9 @@ onMounted(async () => {
|
|||||||
<NotificationDrawer />
|
<NotificationDrawer />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ajuda -->
|
<!-- Ajuda — data-ajuda-toggle ignora este botao no
|
||||||
<button type="button" class="rail-topbar__btn" :class="{ 'rail-topbar__btn--active': ajudaDrawerOpen }" :title="ajudaDrawerOpen ? 'Fechar ajuda' : 'Ajuda'" @click="toggleAjuda">
|
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" />
|
<i class="pi pi-question-circle" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import ProximosFeriadosCard from '@/features/agenda/components/ProximosFeriadosC
|
|||||||
import MelissaAgendaHistoricoCard from './MelissaAgendaHistoricoCard.vue';
|
import MelissaAgendaHistoricoCard from './MelissaAgendaHistoricoCard.vue';
|
||||||
import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue';
|
import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue';
|
||||||
import Popover from 'primevue/popover';
|
import Popover from 'primevue/popover';
|
||||||
import MelissaAgendaSearchPopover from './MelissaAgendaSearchPopover.vue';
|
|
||||||
import MelissaAgendaActionsPopover from './MelissaAgendaActionsPopover.vue';
|
import MelissaAgendaActionsPopover from './MelissaAgendaActionsPopover.vue';
|
||||||
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
|
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
|
||||||
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
|
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
|
||||||
@@ -691,29 +690,15 @@ const fcOptions = computed(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// ── Busca da toolbar (datas + paciente/título) ────────────────
|
// ── Busca da toolbar (datas + paciente/título) ────────────────
|
||||||
// Componente MelissaAgendaSearchPopover (autocontido) gerencia o Cmd+K
|
// Busca agora vive na MelissaBusca (global, Ctrl+K em qualquer tela ou
|
||||||
// search inteiro — input, debounce, parsing de datas, resultados.
|
// botao na .melissa-tray). Aqui fica so a fn gotoDate exposta pro
|
||||||
// Aqui só ficam o ref pro toggle (botão da toolbar + hotkey global) e
|
// MelissaLayout chamar quando o usuario escolhe "Ir para [data]" na
|
||||||
// os 2 handlers que decidem o que fazer com a escolha (gotoDate +
|
// busca global — vide defineExpose mais abaixo.
|
||||||
// auto-select de paciente quando há patient_id no evento).
|
|
||||||
const searchPopover = ref(null);
|
|
||||||
|
|
||||||
function onBuscaGotoDate(date) {
|
function onBuscaGotoDate(date) {
|
||||||
fcApi()?.gotoDate(date);
|
fcApi()?.gotoDate(date);
|
||||||
refDate.value = new Date(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
|
// Card de histórico (audit_logs) — ref pra disparar refetch após
|
||||||
// mutações; handler que abre o evento clicado pelo id.
|
// mutações; handler que abre o evento clicado pelo id.
|
||||||
const historicoCardRef = ref(null);
|
const historicoCardRef = ref(null);
|
||||||
@@ -760,18 +745,8 @@ async function onHistoricoOpen({ id }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atalho global Ctrl/Cmd+K abre a busca via ref do popover.
|
// Ctrl+K e' tratado pela propria MelissaBusca (listener global no
|
||||||
function _onSearchHotkey(e) {
|
// window) — removido o handler local pra nao disparar 2 vezes.
|
||||||
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); });
|
|
||||||
|
|
||||||
// Toolbar — atalhos pra FC API
|
// Toolbar — atalhos pra FC API
|
||||||
function fcApi() {
|
function fcApi() {
|
||||||
@@ -1321,12 +1296,20 @@ function openProntuario(patient) {
|
|||||||
if (!patient?.id) return;
|
if (!patient?.id) return;
|
||||||
abrirProntuarioPorId(patient.id);
|
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({
|
defineExpose({
|
||||||
refetch: refetchEventosFc,
|
refetch: refetchEventosFc,
|
||||||
openProntuario,
|
openProntuario,
|
||||||
setView,
|
setView,
|
||||||
openSessoesPaciente,
|
openSessoesPaciente,
|
||||||
openEditPatient
|
openEditPatient,
|
||||||
|
gotoDate: gotoDateExternal
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1629,21 +1612,10 @@ defineExpose({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Busca — sempre visível. Abre popover com input + lista de
|
<!-- Busca migrou pra .melissa-tray (sempre visivel).
|
||||||
resultados. Suporta data (20/04, hoje) e texto (paciente/
|
Ctrl+K em qualquer tela abre o mesmo spotlight,
|
||||||
título). Ctrl/Cmd+K abre via hotkey global. -->
|
que ja entende data (20/04, hoje, amanha) e
|
||||||
<button
|
paciente/sessao via RPC search_global. -->
|
||||||
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"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Bloquear: ícone-only com Menu popup. Visível só
|
<!-- Bloquear: ícone-only com Menu popup. Visível só
|
||||||
em ≥xl. Em <xl vai pra dentro de "Ações". -->
|
em ≥xl. Em <xl vai pra dentro de "Ações". -->
|
||||||
@@ -2051,9 +2023,9 @@ defineExpose({
|
|||||||
to { opacity: 1; transform: scale(1); }
|
to { opacity: 1; transform: scale(1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* .ma-tsearch* migrou inteiro pra MelissaAgendaSearchPopover.vue (componente
|
/* Busca da agenda migrou inteira pra MelissaBusca (componente global,
|
||||||
autocontido). Cmd+K hotkey global continua aqui no parent — chama
|
no MelissaLayout). Acesso por Ctrl+K em qualquer tela ou pela lupa na
|
||||||
`searchPopover.value?.toggle(...)` apontando pro botao .ma-cal__search-btn. */
|
.melissa-tray (canto inferior direito). Toolbar nao tem mais botao. */
|
||||||
|
|
||||||
/* .ma-actions* migrou inteiro pra MelissaAgendaActionsPopover.vue
|
/* .ma-actions* migrou inteiro pra MelissaAgendaActionsPopover.vue
|
||||||
(componente autocontido, com utilities Tailwind no template). */
|
(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 rootEl = ref(null);
|
||||||
const inputEl = ref(null);
|
const inputEl = ref(null);
|
||||||
@@ -62,12 +62,59 @@ function normalize(s) {
|
|||||||
.trim();
|
.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) {
|
function fmtHora(h) {
|
||||||
const horas = Math.floor(h);
|
const horas = Math.floor(h);
|
||||||
const mins = Math.round((h - horas) * 60);
|
const mins = Math.round((h - horas) * 60);
|
||||||
return `${String(horas).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
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 filteredAtalhos = computed(() => {
|
||||||
const q = normalize(query.value);
|
const q = normalize(query.value);
|
||||||
if (!q) return props.atalhos.slice(0, 4); // 4 defaults quando vazio
|
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 flatList = computed(() => {
|
||||||
const out = [];
|
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 }));
|
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 }));
|
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 }));
|
filteredPacientes.value.forEach((p, i) => out.push({ group: 'pacientes', item: p, idx: i }));
|
||||||
@@ -139,7 +189,8 @@ function findFlatIndex(group, idx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectEntry(entry) {
|
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 === '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 === 'recent') emit('paciente', { id: entry.item.id, nome: entry.item.nome, ...entry.item.extras });
|
||||||
else if (entry.group === 'eventos') emit('evento', entry.item);
|
else if (entry.group === 'eventos') emit('evento', entry.item);
|
||||||
@@ -176,9 +227,17 @@ function onKeydown(e) {
|
|||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === 'ArrowUp') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
activeIndex.value = Math.max(activeIndex.value - 1, 0);
|
activeIndex.value = Math.max(activeIndex.value - 1, 0);
|
||||||
} else if (e.key === 'Enter' && activeIndex.value >= 0) {
|
} else if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
// Enter sem selecao explicita: pega o primeiro item do flatList
|
||||||
selectEntry(flatList.value[activeIndex.value]);
|
// (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)
|
// Escape é tratado pelo Dialog (dismissableMask + closable)
|
||||||
}
|
}
|
||||||
@@ -206,6 +265,14 @@ watch(query, (v) => {
|
|||||||
searching.value = false;
|
searching.value = false;
|
||||||
return;
|
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;
|
searching.value = true;
|
||||||
const mySeq = ++searchSeq;
|
const mySeq = ++searchSeq;
|
||||||
debounceT = setTimeout(async () => {
|
debounceT = setTimeout(async () => {
|
||||||
@@ -241,6 +308,12 @@ onBeforeUnmount(() => {
|
|||||||
window.removeEventListener('keydown', onGlobalKeydown);
|
window.removeEventListener('keydown', onGlobalKeydown);
|
||||||
if (debounceT) clearTimeout(debounceT);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -291,6 +364,25 @@ onBeforeUnmount(() => {
|
|||||||
Nada encontrado pra "<span class="text-white/80">{{ query.trim() }}</span>"
|
Nada encontrado pra "<span class="text-white/80">{{ query.trim() }}</span>"
|
||||||
</div>
|
</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) -->
|
<!-- Acessados recentemente (só quando query vazia) -->
|
||||||
<div v-if="showRecent" class="mb-group">
|
<div v-if="showRecent" class="mb-group">
|
||||||
<div class="mb-group__title">Acessados recentemente</div>
|
<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--doc { color: #7dd3fc; }
|
||||||
:root.app-dark .mb-item__icon--intake { color: #fdba74; }
|
: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 {
|
.mb-item__main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
* Cronômetro de sessão estilo "janela do Windows":
|
* Cronômetro de sessão estilo "janela do Windows":
|
||||||
* - Dialog centralizado com select de paciente, display gigante e ações
|
* - Dialog centralizado com select de paciente, display gigante e ações
|
||||||
* - Click fora minimiza (chip no canto superior esquerdo)
|
* - 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"
|
* - Botão inferior alterna entre "▶ Começar" e "⏹ Parar"
|
||||||
* - "+1 minuto" estende o tempo
|
* - "+1 minuto" estende o tempo
|
||||||
* - Quando minimizado, o timer continua rodando em background
|
* - Quando minimizado, o timer continua rodando em background
|
||||||
@@ -23,8 +24,11 @@
|
|||||||
* cronoRef.value.fechar() // destrói
|
* cronoRef.value.fechar() // destrói
|
||||||
*/
|
*/
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||||
|
import { useConfirm } from 'primevue/useconfirm';
|
||||||
import { playToque } from './melissaToques';
|
import { playToque } from './melissaToques';
|
||||||
|
|
||||||
|
const confirm = useConfirm();
|
||||||
|
|
||||||
const STORAGE_KEY = 'melissa.cronometro.v1';
|
const STORAGE_KEY = 'melissa.cronometro.v1';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -57,6 +61,16 @@ const seconds = ref(props.duracaoMinutos * 60);
|
|||||||
const pacienteId = ref(props.defaultPacienteId);
|
const pacienteId = ref(props.defaultPacienteId);
|
||||||
let timer = null;
|
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).
|
// True só durante a transição de minimizar (dialog → chip).
|
||||||
// Permite trocar a animação leave do dialog por "shrink + fly to dock"
|
// Permite trocar a animação leave do dialog por "shrink + fly to dock"
|
||||||
// (tipo macOS minimize) em vez do lift normal usado pra fechar.
|
// (tipo macOS minimize) em vez do lift normal usado pra fechar.
|
||||||
@@ -90,21 +104,86 @@ const pacienteNome = computed(() => {
|
|||||||
return p ? p.nome : '';
|
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: avisa parent quando dialog aparece/some ─────────────
|
||||||
watch(visible, (v) => emit('visible-change', v));
|
watch(visible, (v) => emit('visible-change', v));
|
||||||
|
|
||||||
// ── Ações ──────────────────────────────────────────────────────
|
// ── 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) {
|
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;
|
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;
|
seconds.value = props.duracaoMinutos * 60;
|
||||||
pacienteId.value = props.defaultPacienteId;
|
pacienteId.value = requestedPid;
|
||||||
running.value = false;
|
running.value = false;
|
||||||
minimized.value = false;
|
minimized.value = false;
|
||||||
exists.value = true;
|
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() {
|
function toggle() {
|
||||||
@@ -161,9 +240,33 @@ function fechar() {
|
|||||||
running.value = false;
|
running.value = false;
|
||||||
minimized.value = false;
|
minimized.value = false;
|
||||||
exists.value = false;
|
exists.value = false;
|
||||||
|
sessionPlan.value = null; // limpa pra proxima abertura comecar zerada
|
||||||
emit('close');
|
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) {
|
function ajustarMinutos(delta) {
|
||||||
seconds.value += delta * 60;
|
seconds.value += delta * 60;
|
||||||
// Re-baselina savedAt pra restore após reload pegar o novo valor correto
|
// Re-baselina savedAt pra restore após reload pegar o novo valor correto
|
||||||
@@ -185,7 +288,8 @@ function saveState() {
|
|||||||
minimized: !!minimized.value,
|
minimized: !!minimized.value,
|
||||||
running: !!running.value,
|
running: !!running.value,
|
||||||
seconds: seconds.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 {}
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(snap)); } catch {}
|
||||||
}
|
}
|
||||||
@@ -223,6 +327,15 @@ function loadState() {
|
|||||||
seconds.value = restoredSeconds;
|
seconds.value = restoredSeconds;
|
||||||
exists.value = true;
|
exists.value = true;
|
||||||
running.value = false; // toggle abaixo flipa pra 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) {
|
if (wasRunning) {
|
||||||
// Retoma o interval. NÃO toca o toque retroativo — se o tempo
|
// 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 nas mudanças de estado discreto (não em seconds enquanto roda — savedAt+delta dá conta)
|
||||||
watch([exists, minimized, running, pacienteId], () => saveState());
|
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 ────────────────────────────────────────────
|
// ── Mount / Cleanup ────────────────────────────────────────────
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadState();
|
loadState();
|
||||||
@@ -242,6 +368,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (timer) clearInterval(timer);
|
if (timer) clearInterval(timer);
|
||||||
|
if (_planNowTimer) clearInterval(_planNowTimer);
|
||||||
});
|
});
|
||||||
|
|
||||||
defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible });
|
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">
|
<button class="mc-glass-btn" title="Minimizar" @click="minimizar">
|
||||||
<i class="pi pi-window-minimize text-white/90 text-xs" />
|
<i class="pi pi-window-minimize text-white/90 text-xs" />
|
||||||
</button>
|
</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" />
|
<i class="pi pi-times text-white/90 text-sm" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,6 +411,17 @@ defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible });
|
|||||||
</select>
|
</select>
|
||||||
<i class="pi pi-chevron-down mc-select-icon" />
|
<i class="pi pi-chevron-down mc-select-icon" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Display gigante + steppers manuais (+5 / -5) -->
|
<!-- Display gigante + steppers manuais (+5 / -5) -->
|
||||||
@@ -436,6 +574,28 @@ defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible });
|
|||||||
pointer-events: none;
|
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 ──────────────────────────────────────── */
|
/* ─── Display gigante ──────────────────────────────────────── */
|
||||||
.mc-display {
|
.mc-display {
|
||||||
font-size: 5rem;
|
font-size: 5rem;
|
||||||
@@ -567,6 +727,14 @@ defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible });
|
|||||||
padding-left: 6px;
|
padding-left: 6px;
|
||||||
border-left: 1px solid var(--m-border-strong);
|
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 {
|
.mc-chip-pulse {
|
||||||
animation: mc-pulse 1.6s ease-in-out infinite;
|
animation: mc-pulse 1.6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ const emit = defineEmits(['cronometro', 'toggle-filtro']);
|
|||||||
class="resumo-link"
|
class="resumo-link"
|
||||||
:class="{ 'is-active': filtroTipo === p.tipo }"
|
:class="{ 'is-active': filtroTipo === p.tipo }"
|
||||||
@click="emit('toggle-filtro', 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>.
|
||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
@@ -135,6 +135,13 @@ const emit = defineEmits(['cronometro', 'toggle-filtro']);
|
|||||||
border-bottom: 1px solid var(--m-text);
|
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" ──────────────────────────
|
/* ─── Modo "fundo nos textos soltos" ──────────────────────────
|
||||||
Toggle no painel Personalizar. Cada bloco de texto (.hero-text)
|
Toggle no painel Personalizar. Cada bloco de texto (.hero-text)
|
||||||
ganha um fundo solido translucido + borda + padding pra ficar
|
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 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"
|
// 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,
|
// Pai so passa eventos brutos + workRules/settings/feriados via props,
|
||||||
// e recebe @evento (pra abrir dialog) + @clear-filter (pra limpar tipo).
|
// 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 contagensDia = computed(() => {
|
||||||
const c = { sessao: 0, supervisao: 0, reuniao: 0 };
|
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;
|
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;
|
return c;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -598,11 +648,34 @@ function pluralizar(n, singular, plural) {
|
|||||||
return `${n} ${n === 1 ? 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
|
// Partes estruturadas pro template renderizar cada contagem como link clicável
|
||||||
const resumoPartes = computed(() => {
|
const resumoPartes = computed(() => {
|
||||||
const c = contagensDia.value;
|
const c = contagensDia.value;
|
||||||
const partes = [];
|
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.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') });
|
if (c.reuniao > 0) partes.push({ tipo: 'reuniao', text: pluralizar(c.reuniao, 'reunião', 'reuniões') });
|
||||||
return partes;
|
return partes;
|
||||||
@@ -1745,6 +1818,22 @@ function _callOnAgenda(action) {
|
|||||||
if (secaoAberta.value !== 'agenda') abrirSecao('agenda');
|
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() {
|
function onAbrirProntuario() {
|
||||||
const ev = eventoSelecionado.value;
|
const ev = eventoSelecionado.value;
|
||||||
if (!ev?.patient_id) {
|
if (!ev?.patient_id) {
|
||||||
@@ -1987,6 +2076,30 @@ const { toqueTermino, testarToque } = useMelissaToques('sino');
|
|||||||
function abrirCronometro() {
|
function abrirCronometro() {
|
||||||
cronoRef.value?.abrir();
|
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() {
|
function fecharCronometro() {
|
||||||
cronoRef.value?.fechar();
|
cronoRef.value?.fechar();
|
||||||
}
|
}
|
||||||
@@ -2347,71 +2460,6 @@ function onKeydown(e) {
|
|||||||
<!-- PLANO DE TRÁS — Resumo (recebe blur quando workspace abre) -->
|
<!-- PLANO DE TRÁS — Resumo (recebe blur quando workspace abre) -->
|
||||||
<!-- ═══════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════ -->
|
||||||
<div class="win11-summary" :class="{ 'is-behind': summaryDimmed }">
|
<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 -->
|
<!-- Conteúdo central -->
|
||||||
<div class="win11-summary__inner">
|
<div class="win11-summary__inner">
|
||||||
<!-- Bloco hero: relógio + data + saudação + resumo do dia -->
|
<!-- 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) -->
|
<!-- Busca rápida (entre o "Hoje há" e a timeline) -->
|
||||||
<MelissaBusca
|
<MelissaBusca
|
||||||
|
ref="melissaBuscaRef"
|
||||||
class="mt-8"
|
class="mt-8"
|
||||||
:pacientes="pacientesReais"
|
:pacientes="pacientesReais"
|
||||||
:eventos="eventosHojeReais"
|
:eventos="eventosHojeReais"
|
||||||
@@ -2436,6 +2485,7 @@ function onKeydown(e) {
|
|||||||
@evento="abrirEvento"
|
@evento="abrirEvento"
|
||||||
@documento="(d) => d?.patient_id ? router.push({ path: '/melissa/paciente', query: { id: String(d.patient_id), tab: 'documentos' } }) : abrirSecao('pacientes')"
|
@documento="(d) => d?.patient_id ? router.push({ path: '/melissa/paciente', query: { id: String(d.patient_id), tab: 'documentos' } }) : abrirSecao('pacientes')"
|
||||||
@intake="() => abrirSecao('cadastros-recebidos')"
|
@intake="() => abrirSecao('cadastros-recebidos')"
|
||||||
|
@goto-date="onBuscaGotoDate"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Timeline horizontal + vertical (responsivo) -->
|
<!-- Timeline horizontal + vertical (responsivo) -->
|
||||||
@@ -2448,6 +2498,7 @@ function onKeydown(e) {
|
|||||||
:filtro-tipo="filtroTipo"
|
:filtro-tipo="filtroTipo"
|
||||||
@evento="abrirEvento"
|
@evento="abrirEvento"
|
||||||
@clear-filter="limparFiltro"
|
@clear-filter="limparFiltro"
|
||||||
|
@iniciar-cronometro="onIniciarCronometroFromEvento"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Cards (catálogo + ativos + layout switchável) -->
|
<!-- Cards (catálogo + ativos + layout switchável) -->
|
||||||
@@ -2460,14 +2511,24 @@ function onKeydown(e) {
|
|||||||
:class="cardsLayout === 'linha-unica' ? 'cards-row--linha' : 'cards-row--wrap'"
|
:class="cardsLayout === 'linha-unica' ? 'cards-row--linha' : 'cards-row--wrap'"
|
||||||
>
|
>
|
||||||
<template v-for="cardId in cardsAtivos" :key="cardId">
|
<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
|
<MelissaCard
|
||||||
v-if="cardId === 'proximo-paciente'"
|
v-if="cardId === 'proximo-paciente'"
|
||||||
icon="pi pi-user"
|
icon="pi pi-user"
|
||||||
icon-color="text-emerald-300"
|
icon-color="text-emerald-300"
|
||||||
title="Próximo paciente"
|
title="Próximo paciente"
|
||||||
:action-title="proximoPaciente ? 'Abrir sessão' : 'Abrir Pacientes'"
|
:action-title="proximoPaciente
|
||||||
@open="proximoPaciente ? abrirEvento(proximoPaciente.ev) : abrirSecao('pacientes')"
|
? (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 v-if="proximoPaciente" class="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
@@ -2613,14 +2674,163 @@ function onKeydown(e) {
|
|||||||
<span class="psi-kbd" aria-hidden="true">Ctrl \</span>
|
<span class="psi-kbd" aria-hidden="true">Ctrl \</span>
|
||||||
</button>
|
</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 -->
|
<!-- DOCK (taskbar Win11-style sem chrome) — receptáculo pra -->
|
||||||
<!-- elementos minimizados (cronômetro, futuros) + atalhos -->
|
<!-- elementos minimizados (cronômetro, futuros) + atalhos -->
|
||||||
<!-- pinned (Agenda, WhatsApp). Transparent, só os items são -->
|
<!-- pinned (Agenda, Pacientes, WhatsApp, Financeiro). Transp.-->
|
||||||
<!-- clicáveis. ψ vive ao lado (absolute, bottom-left). -->
|
<!-- só os items sao clicaveis. ψ vive ao lado (absolute, -->
|
||||||
|
<!-- bottom-left). -->
|
||||||
<!-- ═══════════════════════════════════════════════════════ -->
|
<!-- ═══════════════════════════════════════════════════════ -->
|
||||||
<div class="melissa-dock">
|
<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
|
Tamanho menor que ψ (44px vs 56px) + cantos arredondados
|
||||||
mas não full-circle, pra hierarquia visual ficar óbvia. -->
|
mas não full-circle, pra hierarquia visual ficar óbvia. -->
|
||||||
<button
|
<button
|
||||||
@@ -2632,6 +2842,15 @@ function onKeydown(e) {
|
|||||||
>
|
>
|
||||||
<i class="pi pi-calendar" />
|
<i class="pi pi-calendar" />
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="dock-pin"
|
class="dock-pin"
|
||||||
@@ -2646,12 +2865,24 @@ function onKeydown(e) {
|
|||||||
:title="`${whatsappPendente.count} mensagens não lidas`"
|
:title="`${whatsappPendente.count} mensagens não lidas`"
|
||||||
>{{ whatsappPendente.count > 99 ? '99+' : whatsappPendente.count }}</span>
|
>{{ whatsappPendente.count > 99 ? '99+' : whatsappPendente.count }}</span>
|
||||||
</button>
|
</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
|
<!-- 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
|
<div
|
||||||
v-if="dockPins.pinned.value.length || dockPins.recent.value.length"
|
v-if="dockPins.pinned.value.length || dockPins.recent.value.length"
|
||||||
class="dock-divider"
|
class="dock-divider"
|
||||||
|
:class="{ 'hidden md:block': !dockPins.pinned.value.length }"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -2671,7 +2902,11 @@ function onKeydown(e) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Pins MRU (max 3) — empurrados pelas últimas seções abertas.
|
<!-- 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
|
<button
|
||||||
v-for="slug in dockPins.recent.value" :key="`r-${slug}`"
|
v-for="slug in dockPins.recent.value" :key="`r-${slug}`"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -3856,7 +4091,7 @@ function onKeydown(e) {
|
|||||||
.settings-pop-enter-from,
|
.settings-pop-enter-from,
|
||||||
.settings-pop-leave-to {
|
.settings-pop-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-6px) scale(0.98);
|
transform: translateY(6px) scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* line-clamp util (caso Tailwind não tenha) */
|
/* 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);
|
--m-hero-text-border: rgba(255, 255, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Faixa de fundo do topbar (canto sup. direito) ──────────────
|
/* ─── Tray Melissa (system tray win11-style, canto inferior direito)
|
||||||
Gradiente horizontal: cor solida na direita (onde os icones vivem)
|
Posiciona o grupo de icones globais (plan-DEV, notificacoes, ajuda,
|
||||||
e fade pra transparente na esquerda. z-index abaixo do topbar
|
cog) alinhado verticalmente com os pins do dock. z-index acima do
|
||||||
(z-30) e acima do conteudo principal. */
|
dock (65) pra ficar no mesmo plano interativo. */
|
||||||
.melissa-topbar-band {
|
.melissa-tray {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
bottom: 0;
|
||||||
left: 0;
|
right: 1.25rem;
|
||||||
right: 0;
|
height: var(--m-dock-h, 76px);
|
||||||
height: 80px;
|
z-index: 66;
|
||||||
z-index: 25;
|
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;
|
pointer-events: none;
|
||||||
background: linear-gradient(
|
}
|
||||||
to left,
|
|
||||||
var(--m-band) 0%,
|
/* Popup vertical do "More" — abre acima do botao trigger, mesmo padrao
|
||||||
var(--m-band) 25%,
|
visual do MelissaSettingsPanel mas mais compacto. */
|
||||||
transparent 75%
|
.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
|
/* ─── 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 {
|
.dock-pin--recent.dock-pin--active {
|
||||||
opacity: 1;
|
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
|
/* ─── Skeleton loading utilitário (global pra atravessar scoped CSS dos
|
||||||
componentes filhos). Use a classe .melissa-skeleton em qualquer
|
componentes filhos). Use a classe .melissa-skeleton em qualquer
|
||||||
|
|||||||
@@ -313,6 +313,9 @@ async function refetchTudo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Estado de UI ───────────────────────────────────────────────
|
// ── 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 busca = ref('');
|
||||||
const statusFiltro = ref('ativos'); // 'todos' | 'ativos' | 'inativos' | 'arquivados'
|
const statusFiltro = ref('ativos'); // 'todos' | 'ativos' | 'inativos' | 'arquivados'
|
||||||
const grupoFiltroId = ref(null); // null = todos
|
const grupoFiltroId = ref(null); // null = todos
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ function onClearBg() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 -->
|
<!-- Cabecalho fixo -->
|
||||||
<header class="mp-head">
|
<header class="mp-head">
|
||||||
<div class="mp-head__title">
|
<div class="mp-head__title">
|
||||||
|
|||||||
@@ -36,7 +36,13 @@ const props = defineProps({
|
|||||||
filtroTipo: { type: String, default: null }
|
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:
|
// Range de horas (HORA_INICIO/HORA_FIM) — derivado de:
|
||||||
@@ -382,6 +388,19 @@ onMounted(() => {
|
|||||||
:class="['tl-event-pill__status', statusIcon(ev)]"
|
:class="['tl-event-pill__status', statusIcon(ev)]"
|
||||||
aria-hidden="true"
|
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>
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 h-full flex flex-col items-center pointer-events-none z-20"
|
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) }}
|
{{ fmtHora(ev.startH) }} – {{ fmtHora(ev.endH) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="vt-event-label">{{ ev.label }}</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>
|
||||||
|
|
||||||
<div class="vt-now" :style="{ top: nowCursorTop }">
|
<div class="vt-now" :style="{ top: nowCursorTop }">
|
||||||
@@ -640,6 +671,46 @@ html:not(.app-dark) .tl-day-badge--feriado {
|
|||||||
margin-left: 0;
|
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 */
|
/* Realizado: glow verde sutil (cor do bg ja eh verde) — celebra o feito */
|
||||||
.tl-pill--realizado {
|
.tl-pill--realizado {
|
||||||
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.28), 0 4px 12px rgba(16, 185, 129, 0.18);
|
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.28), 0 4px 12px rgba(16, 185, 129, 0.18);
|
||||||
|
|||||||
Reference in New Issue
Block a user