Files
agenciapsilmno/src/layout/melissa/MelissaCronometro.vue
T
Leonardo 06bce11e1c Melissa: deep-link via URL + Pacientes (WIP) + cronometro reset
Roteamento por URL (substitui o ref local secaoAberta):
- routes.misc.js: rota vira /preview/melissa/:secao? — param opcional
- MelissaLayout.vue: secaoAberta agora e computed do route.params.secao,
  validado contra SECOES (chave invalida -> null). abrirSecao/fecharSecao
  fazem router.push em vez de mutar ref. Habilita back/forward, refresh
  e deep-link tipo /preview/melissa/agenda.

Pagina Pacientes (WIP, ainda nao wireada no slot do Layout):
- src/layout/melissa/MelissaPacientes.vue (novo, ~? linhas) — fullscreen
  3-col espelhando MelissaAgenda: aside esquerda com filtros (status /
  grupos / tags), lista central com cards + busca, quick view direita
  com KPIs do paciente selecionado + acoes.
- Carrega pacientes (todos os status), grupos/tags do tenant, vinculos
  patient_groups + patient_tags + session counts em paralelo.
- Integra PatientProntuario (overlay), PatientCadastroDialog,
  PatientCreatePopover + ComponentCadastroRapido, e
  conversationDrawerStore (acao WhatsApp da quick view).

useMelissaPacientes ganha opcao { onlyActive }:
- default true (compat com cards do resumo / cronometro / eventos hoje
  — so faz sentido com ativos)
- false retorna Ativo + Inativo + Arquivado, pra uso na pagina nova
- select agora inclui data_nascimento (necessario pros KPIs da quick view)

Cronometro: zera ao parar — terminou a sessao, fica pronto pra proxima
sem precisar reabrir o popover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:12:15 -03:00

623 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
/*
* MelissaCronometro
* --------------------------------------------------
* Cronômetro de sessão estilo "janela do Windows":
* - Dialog centralizado com select de paciente, display gigante e ações
* - Click fora minimiza (chip no canto superior esquerdo)
* - X/ESC fecha (destrói)
* - Botão inferior alterna entre "▶ Começar" e "⏹ Parar"
* - "+1 minuto" estende o tempo
* - Quando minimizado, o timer continua rodando em background
*
* Uso:
* <MelissaCronometro
* ref="cronoRef"
* :pacientes="lista"
* default-paciente-id="p1"
* :duracao-minutos="50"
* @visible-change="cronoVisivel = $event"
* />
*
* cronoRef.value.abrir() // abre / restaura
* cronoRef.value.fechar() // destrói
*/
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { playToque } from './melissaToques';
const STORAGE_KEY = 'melissa.cronometro.v1';
const props = defineProps({
pacientes: {
type: Array,
default: () => []
// Cada item: { id: string, nome: string }
},
defaultPacienteId: {
type: [String, Number, null],
default: null
},
duracaoMinutos: {
type: Number,
default: 50
},
toqueTermino: {
type: String,
default: 'sino'
}
});
const emit = defineEmits(['visible-change', 'close', 'complete']);
// ── Estado interno ─────────────────────────────────────────────
const exists = ref(false);
const minimized = ref(false);
const running = ref(false);
const seconds = ref(props.duracaoMinutos * 60);
const pacienteId = ref(props.defaultPacienteId);
let timer = null;
// True só durante a transição de minimizar (dialog → chip).
// Permite trocar a animação leave do dialog por "shrink + fly to dock"
// (tipo macOS minimize) em vez do lift normal usado pra fechar.
const isMinimizing = ref(false);
// ── Computeds ──────────────────────────────────────────────────
const visible = computed(() => exists.value && !minimized.value);
const dialogTransitionName = computed(() => (isMinimizing.value ? 'minimize' : 'lift'));
const display = computed(() => {
const total = seconds.value;
const sign = total < 0 ? '-' : '';
const abs = Math.abs(total);
const m = Math.floor(abs / 60);
const s = abs % 60;
return `${sign}${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
});
const excedido = computed(() => seconds.value < 0);
const status = computed(() => {
if (running.value) return 'Em andamento';
if (seconds.value === props.duracaoMinutos * 60) return 'Pronto';
return 'Pausado';
});
const pacienteNome = computed(() => {
if (!pacienteId.value) return 'Atividade livre';
const p = props.pacientes.find((x) => x.id === pacienteId.value);
return p ? p.nome : '';
});
// ── Watch: avisa parent quando dialog aparece/some ─────────────
watch(visible, (v) => emit('visible-change', v));
// ── Ações ──────────────────────────────────────────────────────
function abrir() {
if (exists.value) {
// Já existe — apenas restaura se tava minimizado (não cria outro)
if (minimized.value) minimized.value = false;
return;
}
seconds.value = props.duracaoMinutos * 60;
pacienteId.value = props.defaultPacienteId;
running.value = false;
minimized.value = false;
exists.value = true;
}
function toggle() {
if (running.value) {
if (timer) clearInterval(timer);
timer = null;
running.value = false;
// Zera ao parar — sessão acabou, deixa pronto pra próxima
seconds.value = props.duracaoMinutos * 60;
} else {
if (timer) clearInterval(timer);
timer = setInterval(() => {
const wasPositive = seconds.value > 0;
seconds.value -= 1;
// Toca exatamente na transição positivo → zero/negativo (uma única vez)
if (wasPositive && seconds.value <= 0) {
playToque(props.toqueTermino);
}
}, 1000);
running.value = true;
}
}
function minimizar() {
isMinimizing.value = true;
minimized.value = true;
// Reset do flag depois da animação completar (340ms + buffer)
setTimeout(() => { isMinimizing.value = false; }, 380);
}
function restaurar() {
minimized.value = false;
}
function fechar() {
if (timer) clearInterval(timer);
timer = null;
running.value = false;
minimized.value = false;
exists.value = false;
emit('close');
}
function ajustarMinutos(delta) {
seconds.value += delta * 60;
// Re-baselina savedAt pra restore após reload pegar o novo valor correto
saveState();
}
// ── Persistência (localStorage) ────────────────────────────────
// Snapshot inclui timestamp pra calcular elapsed real no restore —
// caso o usuário recarregue/feche a aba enquanto o timer roda, ao
// voltar o seconds reflete o tempo real (a sessão acontece na sala,
// não na aba).
function saveState() {
if (!exists.value) {
try { localStorage.removeItem(STORAGE_KEY); } catch {}
return;
}
const snap = {
pacienteId: pacienteId.value,
minimized: !!minimized.value,
running: !!running.value,
seconds: seconds.value,
savedAt: Date.now()
};
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(snap)); } catch {}
}
function loadState() {
let raw;
try { raw = localStorage.getItem(STORAGE_KEY); } catch { return false; }
if (!raw) return false;
let snap;
try { snap = JSON.parse(raw); } catch { return false; }
if (!snap || typeof snap !== 'object') return false;
// Sanitização: cada campo do storage é input externo, não confiar
const validIds = new Set(props.pacientes.map((p) => p.id));
const pid = snap.pacienteId === null || validIds.has(snap.pacienteId)
? snap.pacienteId
: props.defaultPacienteId;
const savedSeconds = Number(snap.seconds);
if (!Number.isFinite(savedSeconds)) return false;
const savedAt = Number(snap.savedAt);
const wasRunning = !!snap.running;
// Se rodava, desconta tempo real desde o save (limite 24h pra evitar abuso de relógio)
let restoredSeconds = savedSeconds;
if (wasRunning && Number.isFinite(savedAt)) {
const elapsed = Math.max(0, Math.min(86400, Math.floor((Date.now() - savedAt) / 1000)));
restoredSeconds = savedSeconds - elapsed;
}
pacienteId.value = pid;
minimized.value = !!snap.minimized;
seconds.value = restoredSeconds;
exists.value = true;
running.value = false; // toggle abaixo flipa pra true
if (wasRunning) {
// Retoma o interval. NÃO toca o toque retroativo — se o tempo
// já está negativo no restore, foi "perdido" durante o reload.
toggle();
}
return true;
}
// Watch nas mudanças de estado discreto (não em seconds enquanto roda — savedAt+delta dá conta)
watch([exists, minimized, running, pacienteId], () => saveState());
// ── Mount / Cleanup ────────────────────────────────────────────
onMounted(() => {
loadState();
});
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
});
defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible });
</script>
<template>
<!-- Dialog centralizado.
Transition name dinâmico: 'minimize' quando user minimiza (anima
shrink + fly em direção ao dock no bottom-left), 'lift' default
pros outros casos (fechar, abrir). -->
<Transition :name="dialogTransitionName">
<div v-if="visible" class="mc-layer" @click.self="minimizar">
<div class="mc-panel">
<div class="flex items-center justify-between mb-5">
<div>
<div class="text-white/55 text-xs uppercase tracking-[0.2em]">Cronômetro</div>
<div class="text-white text-lg font-light mt-1">{{ status }}</div>
</div>
<div class="flex items-center gap-1.5">
<button class="mc-glass-btn" title="Minimizar" @click="minimizar">
<i class="pi pi-window-minimize text-white/90 text-xs" />
</button>
<button class="mc-glass-btn" title="Fechar" @click="fechar">
<i class="pi pi-times text-white/90 text-sm" />
</button>
</div>
</div>
<!-- Select de paciente / atividade -->
<div class="mb-6">
<label class="text-[0.65rem] uppercase tracking-widest text-white/50 mb-2 block">
Paciente / atividade
</label>
<div class="relative">
<select v-model="pacienteId" class="mc-select">
<option :value="null"> Atividade livre (sem paciente)</option>
<option v-for="p in pacientes" :key="p.id" :value="p.id">
{{ p.nome }}
</option>
</select>
<i class="pi pi-chevron-down mc-select-icon" />
</div>
</div>
<!-- Display gigante + steppers manuais (+5 / -5) -->
<div class="mb-7">
<div class="flex items-center justify-center gap-4">
<div class="flex flex-col gap-1.5">
<button
class="mc-step-btn"
title="Adicionar 5 minutos"
@click="ajustarMinutos(5)"
>
+5
</button>
<button
class="mc-step-btn"
title="Remover 5 minutos"
:disabled="excedido"
@click="ajustarMinutos(-5)"
>
5
</button>
</div>
<div class="mc-display" :class="{ 'is-excedido': excedido }">
{{ display }}
</div>
</div>
<div class="text-xs mt-2 text-center" :class="excedido ? 'text-red-400 font-medium' : 'text-white/50'">
{{ excedido ? 'tempo excedido' : 'tempo restante' }}
</div>
</div>
<!-- Ações -->
<div class="flex gap-3">
<button class="mc-btn mc-btn--secondary flex-1" @click="ajustarMinutos(1)">
+1 minuto
</button>
<button
class="mc-btn flex-1"
:class="running ? 'mc-btn--danger' : 'mc-btn--primary'"
@click="toggle"
>
<i :class="running ? 'pi pi-stop-circle' : 'pi pi-play'" class="text-xs" />
{{ running ? 'Parar' : 'Começar' }}
</button>
</div>
</div>
</div>
</Transition>
<!-- Chip minimizado teleporta pro .melissa-dock (taskbar Win11
no bottom). Vive ao lado do ψ (à direita dele, dentro do flex
do dock). Transition envolve o Teleport (pattern oficial Vue):
o Teleport some/aparece como unidade, sem deixar comment
placeholder no target compartilhado o que evitaria o bug
"emitsOptions: null" causado por múltiplos Teleports + v-if
interno apontando pro mesmo target. -->
<Transition name="chip-pop">
<Teleport v-if="exists && minimized" to=".melissa-dock">
<button
class="mc-chip"
title="Restaurar cronômetro"
@click="restaurar"
>
<i
class="pi pi-stopwatch text-sm"
:class="running ? 'text-emerald-300 mc-chip-pulse' : 'text-white/70'"
/>
<span class="mc-chip-time" :class="{ 'text-red-300': excedido }">
{{ display }}
</span>
<span class="mc-chip-name">{{ pacienteNome }}</span>
</button>
</Teleport>
</Transition>
</template>
<style scoped>
/* ─── Dialog ───────────────────────────────────────────────── */
.mc-layer {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.mc-panel {
width: min(420px, 100%);
background: var(--m-bg-medium);
backdrop-filter: blur(32px) saturate(160%);
-webkit-backdrop-filter: blur(32px) saturate(160%);
border: 1px solid var(--m-border-strong);
border-radius: 22px;
padding: 1.75rem;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
.mc-glass-btn {
width: 36px;
height: 36px;
display: grid;
place-items: center;
background: var(--m-bg-soft);
backdrop-filter: blur(20px);
border: 1px solid var(--m-border-strong);
border-radius: 9999px;
cursor: pointer;
transition: background-color 160ms ease;
}
.mc-glass-btn:hover {
background: var(--m-bg-soft-hover);
}
/* ─── Select customizado ───────────────────────────────────── */
.mc-select {
width: 100%;
background: var(--m-bg-soft);
border: 1px solid var(--m-border-strong);
color: var(--m-text);
padding: 11px 38px 11px 14px;
border-radius: 10px;
font-size: 0.95rem;
font-family: inherit;
outline: none;
appearance: none;
-webkit-appearance: none;
cursor: pointer;
transition: border-color 140ms ease, background-color 140ms ease;
}
.mc-select:hover {
background: var(--m-bg-soft-hover);
}
.mc-select:focus {
border-color: var(--m-border-strong);
}
.mc-select option {
/* renderizado pelo OS — usa tokens semânticos pra acompanhar dark/light */
background: var(--p-content-background);
color: var(--m-text);
}
.mc-select-icon {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--m-text-muted);
font-size: 0.7rem;
pointer-events: none;
}
/* ─── Display gigante ──────────────────────────────────────── */
.mc-display {
font-size: 5rem;
font-weight: 200;
line-height: 1;
letter-spacing: -0.04em;
color: var(--m-text);
font-variant-numeric: tabular-nums;
text-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
transition: color 200ms ease;
}
.mc-display.is-excedido {
color: #ef4444;
text-shadow: 0 4px 24px rgba(239, 68, 68, 0.45);
animation: mc-excedido-pulse 1.4s ease-in-out infinite;
}
@keyframes mc-excedido-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.65; }
}
/* ─── Steppers manuais (+5 / -5) ───────────────────────────── */
.mc-step-btn {
width: 44px;
height: 36px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border-strong);
color: var(--m-text);
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
font-family: inherit;
cursor: pointer;
transition: background-color 140ms ease, border-color 140ms ease, transform 100ms ease;
}
.mc-step-btn:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mc-step-btn:active {
transform: scale(0.96);
}
.mc-step-btn:disabled {
background: var(--m-bg-soft);
border-color: var(--m-border);
color: var(--m-text-faint);
cursor: not-allowed;
pointer-events: none;
}
/* ─── Botões de ação ───────────────────────────────────────── */
.mc-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px 14px;
border-radius: 12px;
font-size: 0.9rem;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: background-color 140ms ease, border-color 140ms ease;
}
.mc-btn--secondary {
background: var(--m-bg-soft);
border: 1px solid var(--m-border-strong);
color: var(--m-text);
}
.mc-btn--secondary:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mc-btn--primary {
background: var(--m-accent);
border: 1px solid var(--m-accent);
color: var(--p-primary-contrast-color, white);
}
.mc-btn--primary:hover {
background: color-mix(in srgb, var(--m-accent) 80%, white);
}
.mc-btn--danger {
background: rgba(239, 68, 68, 0.55);
border: 1px solid rgba(239, 68, 68, 0.7);
color: white;
}
.mc-btn--danger:hover {
background: rgba(239, 68, 68, 0.75);
}
/* ─── Chip minimizado (teleportado pro .melissa-dock) ──────
Vive como filho flex do dock — não usa position fixed. Dock
posiciona ele à esquerda (depois do ψ, via padding-left). */
.mc-chip {
position: relative;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 8px 14px 8px 12px;
background: var(--m-bg-medium);
backdrop-filter: blur(24px) saturate(160%);
-webkit-backdrop-filter: blur(24px) saturate(160%);
border: 1px solid var(--m-border-strong);
border-radius: 9999px;
color: var(--m-text);
cursor: pointer;
transition: background-color 200ms ease, transform 200ms ease, border-color 200ms ease;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
.mc-chip:hover {
background: var(--m-bg-medium);
transform: translateY(-2px);
border-color: var(--m-border-strong);
}
.mc-chip-time {
font-variant-numeric: tabular-nums;
font-weight: 500;
font-size: 0.85rem;
}
.mc-chip-name {
font-size: 0.72rem;
color: var(--m-text-muted);
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-left: 6px;
border-left: 1px solid var(--m-border-strong);
}
.mc-chip-pulse {
animation: mc-pulse 1.6s ease-in-out infinite;
}
@keyframes mc-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ─── Transições ───────────────────────────────────────────── */
.lift-enter-active,
.lift-leave-active {
transition: opacity 240ms ease, transform 280ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
.lift-enter-from {
opacity: 0;
transform: scale(0.96) translateY(12px);
}
.lift-leave-to {
opacity: 0;
transform: scale(0.98) translateY(8px);
}
.chip-pop-enter-active,
.chip-pop-leave-active {
transition: opacity 200ms ease, transform 220ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
.chip-pop-enter-from,
.chip-pop-leave-to {
opacity: 0;
transform: scale(0.85) translateY(-8px);
}
/* Chip aparecendo após minimize: leve atraso pra deixar o dialog
"voar" antes de aparecer no dock (sensação macOS) */
.chip-pop-enter-active {
transition-delay: 120ms;
}
/* ─── Animação "minimize" (macOS-style): dialog encolhe + voa pro
bottom-left em direção ao chip que vai aparecer no dock. Aplicada
só durante minimizar() — fechar normal usa lift. ────────────── */
.minimize-enter-active,
.minimize-leave-active {
transition: opacity 280ms ease, transform 340ms cubic-bezier(0.5, 0, 0.6, 1);
/* origem ~= posição do chip no dock (left:96px após ψ, bottom centro
da faixa de 76px = ~38px). Faz o scale "encolher em direção" lá. */
transform-origin: 96px calc(100% - 38px);
}
.minimize-enter-from {
opacity: 0;
transform: scale(0.05);
}
.minimize-leave-to {
opacity: 0;
transform: scale(0.05);
}
/* ─── Light mode tweaks ────────────────────────────────────
Texto já usa var(--m-text) (flipa automático), mas alguns
shadows escuros agressivos precisam ser suavizados. */
html:not(.app-dark) .mc-display {
text-shadow: none;
}
html:not(.app-dark) .mc-chip {
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(15, 23, 42, 0.06);
}
html:not(.app-dark) .mc-panel {
box-shadow: 0 12px 36px rgba(15, 23, 42, 0.12), 0 2px 6px rgba(15, 23, 42, 0.06);
}
</style>