diff --git a/src/layout/melissa/MelissaCronometro.vue b/src/layout/melissa/MelissaCronometro.vue index c1ae701..54a42ea 100644 --- a/src/layout/melissa/MelissaCronometro.vue +++ b/src/layout/melissa/MelissaCronometro.vue @@ -5,7 +5,8 @@ * Cronômetro de sessão estilo "janela do Windows": * - Dialog centralizado com select de paciente, display gigante e ações * - Click fora minimiza (chip no canto superior esquerdo) - * - X/ESC fecha (destrói) + * - X = encerrar sem salvar. Com confirmacao se houver sessao em + * andamento ou tempo decorrido (fechar limpo nao pede confirm) * - Botão inferior alterna entre "▶ Começar" e "⏹ Parar" * - "+1 minuto" estende o tempo * - Quando minimizado, o timer continua rodando em background @@ -23,8 +24,11 @@ * cronoRef.value.fechar() // destrói */ import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'; +import { useConfirm } from 'primevue/useconfirm'; import { playToque } from './melissaToques'; +const confirm = useConfirm(); + const STORAGE_KEY = 'melissa.cronometro.v1'; const props = defineProps({ @@ -57,6 +61,16 @@ const seconds = ref(props.duracaoMinutos * 60); const pacienteId = ref(props.defaultPacienteId); let timer = null; +// Plano da sessao (horario programado original do evento na agenda). +// Setado quando o cronometro abre a partir de um evento da timeline — +// vide abrir({ sessionPlan: { startH, endH } }). Null quando aberto +// manualmente. Persiste em localStorage junto com o resto do snap. +const sessionPlan = ref(null); // { startH: number, endH: number } | null +// Tick a cada 30s pra recomputar atraso conforme o tempo passa (so +// quando cronometro existe; reseta no fechar). +const _planNowTick = ref(Date.now()); +let _planNowTimer = null; + // True só durante a transição de minimizar (dialog → chip). // Permite trocar a animação leave do dialog por "shrink + fly to dock" // (tipo macOS minimize) em vez do lift normal usado pra fechar. @@ -90,21 +104,86 @@ const pacienteNome = computed(() => { return p ? p.nome : ''; }); +// Formatador hh:mm a partir do startH decimal (ex: 11.5 → "11:30"). +function _fmtHora(h) { + if (typeof h !== 'number' || Number.isNaN(h)) return ''; + const hh = Math.floor(h); + const mm = Math.round((h - hh) * 60); + return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`; +} + +const sessionPlanLabel = computed(() => { + const p = sessionPlan.value; + if (!p || typeof p.startH !== 'number' || typeof p.endH !== 'number') return ''; + return `Programado: ${_fmtHora(p.startH)} – ${_fmtHora(p.endH)}`; +}); + +// Atraso em minutos vs horario programado. Calcula em relacao ao +// _planNowTick (atualizado a cada 30s) pra nao recomputar a cada frame. +const atrasoMin = computed(() => { + const p = sessionPlan.value; + if (!p || typeof p.startH !== 'number') return 0; + // ref usada so pra forcar recompute periodico + void _planNowTick.value; + const d = new Date(); + const hNow = d.getHours() + d.getMinutes() / 60; + const diff = hNow - p.startH; + if (diff <= 0) return 0; + return Math.round(diff * 60); +}); + // ── Watch: avisa parent quando dialog aparece/some ───────────── watch(visible, (v) => emit('visible-change', v)); // ── Ações ────────────────────────────────────────────────────── -function abrir() { +// abrir({ pacienteId, autostart }) — opts permitem pre-selecionar +// um paciente (ex: click no botao "iniciar sessao" da timeline) e +// auto-iniciar a contagem. Sem opts mantem comportamento legado. +// Retorna { opened, alreadyRunning, pacienteId } pra caller decidir +// (ex: mostrar toast se ja tem cronometro rodando de outro paciente). +function abrir(opts = {}) { + const requestedPid = Object.prototype.hasOwnProperty.call(opts, 'pacienteId') + ? opts.pacienteId + : props.defaultPacienteId; if (exists.value) { - // Já existe — apenas restaura se tava minimizado (não cria outro) + // Ja existe — comportamento opcao (b): nao troca paciente. Apenas + // restaura visualmente se estava minimizado. Caller decide se + // mostra toast quando o paciente requisitado e' diferente do atual. if (minimized.value) minimized.value = false; - return; + return { + opened: false, + alreadyRunning: !!running.value, + pacienteId: pacienteId.value, + samePaciente: pacienteId.value === requestedPid + }; } seconds.value = props.duracaoMinutos * 60; - pacienteId.value = props.defaultPacienteId; + pacienteId.value = requestedPid; running.value = false; minimized.value = false; exists.value = true; + // Plano programado (vem do evento da timeline) — usado pra exibir + // "Programado: HH:MM – HH:MM" e badge de atraso. Sanitiza pra + // garantir 2 numeros validos; senao limpa. + const plan = opts.sessionPlan; + if (plan && typeof plan.startH === 'number' && typeof plan.endH === 'number' + && !Number.isNaN(plan.startH) && !Number.isNaN(plan.endH)) { + sessionPlan.value = { startH: plan.startH, endH: plan.endH }; + } else { + sessionPlan.value = null; + } + if (opts.autostart) { + // Defer pra rodar dps do mount/render do dialog (toggle precisa + // do setInterval em proximo tick pra contar a partir do segundo + // cheio, nao perde a fracao do tick atual). + setTimeout(() => { if (exists.value && !running.value) toggle(); }, 0); + } + return { + opened: true, + alreadyRunning: false, + pacienteId: pacienteId.value, + samePaciente: true + }; } function toggle() { @@ -161,9 +240,33 @@ function fechar() { running.value = false; minimized.value = false; exists.value = false; + sessionPlan.value = null; // limpa pra proxima abertura comecar zerada emit('close'); } +// Fechar com confirmacao quando ha sessao em andamento ou tempo +// decorrido sem salvar. Estado "clean" (parado + nada decorrido) +// fecha direto pra nao atrapalhar quem abriu por engano. +function confirmarFechar() { + const totalInicial = props.duracaoMinutos * 60; + const temAtividade = running.value || seconds.value !== totalInicial; + if (!temAtividade) { + fechar(); + return; + } + confirm.require({ + message: running.value + ? 'Sessão em andamento — encerrar agora descarta o tempo cronometrado sem salvar no DB.' + : 'O cronômetro tem tempo decorrido que ainda não foi salvo. Quer descartar?', + header: 'Encerrar sessão sem salvar?', + icon: 'pi pi-exclamation-triangle', + acceptLabel: 'Encerrar sem salvar', + rejectLabel: 'Continuar sessão', + acceptClass: 'p-button-danger', + accept: () => fechar() + }); +} + function ajustarMinutos(delta) { seconds.value += delta * 60; // Re-baselina savedAt pra restore após reload pegar o novo valor correto @@ -185,7 +288,8 @@ function saveState() { minimized: !!minimized.value, running: !!running.value, seconds: seconds.value, - savedAt: Date.now() + savedAt: Date.now(), + sessionPlan: sessionPlan.value // null | { startH, endH } }; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(snap)); } catch {} } @@ -223,6 +327,15 @@ function loadState() { seconds.value = restoredSeconds; exists.value = true; running.value = false; // toggle abaixo flipa pra true + // Restaura plano programado se foi serializado. Sanitiza shape: + // {startH:number, endH:number} valido ou null. + const sp = snap.sessionPlan; + if (sp && typeof sp.startH === 'number' && typeof sp.endH === 'number' + && !Number.isNaN(sp.startH) && !Number.isNaN(sp.endH)) { + sessionPlan.value = { startH: sp.startH, endH: sp.endH }; + } else { + sessionPlan.value = null; + } if (wasRunning) { // Retoma o interval. NÃO toca o toque retroativo — se o tempo @@ -235,6 +348,19 @@ function loadState() { // Watch nas mudanças de estado discreto (não em seconds enquanto roda — savedAt+delta dá conta) watch([exists, minimized, running, pacienteId], () => saveState()); +// Tick a cada 30s pra recomputar atraso. So roda quando o cronometro +// existe E tem plano programado — senao desperdicio. +watch([exists, sessionPlan], ([e, p]) => { + if (e && p) { + if (!_planNowTimer) { + _planNowTimer = setInterval(() => { _planNowTick.value = Date.now(); }, 30_000); + } + } else if (_planNowTimer) { + clearInterval(_planNowTimer); + _planNowTimer = null; + } +}); + // ── Mount / Cleanup ──────────────────────────────────────────── onMounted(() => { loadState(); @@ -242,6 +368,7 @@ onMounted(() => { onBeforeUnmount(() => { if (timer) clearInterval(timer); + if (_planNowTimer) clearInterval(_planNowTimer); }); defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible }); @@ -264,7 +391,7 @@ defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible }); - @@ -284,6 +411,17 @@ defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible }); + +
+ + {{ sessionPlanLabel }} + + atrasada {{ atrasoMin }} min + +
@@ -436,6 +574,28 @@ defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible }); pointer-events: none; } +/* Plano programado — linha abaixo do select com horario original e + badge de atraso quando aplicavel. Tom secundario pra nao roubar + atencao do display gigante do cronometro. */ +.mc-session-plan { + display: flex; + align-items: center; + gap: 6px; + margin-top: 8px; + padding: 4px 0; +} +.mc-session-plan__late { + margin-left: 4px; + padding: 1px 8px; + border-radius: 9999px; + background: rgba(251, 146, 60, 0.18); + color: rgb(253, 186, 116); + font-size: 0.7rem; + font-weight: 500; + line-height: 1.4; + border: 1px solid rgba(251, 146, 60, 0.35); +} + /* ─── Display gigante ──────────────────────────────────────── */ .mc-display { font-size: 5rem; @@ -567,6 +727,14 @@ defineExpose({ abrir, fechar, minimizar, restaurar, toggle, visible }); padding-left: 6px; border-left: 1px solid var(--m-border-strong); } +/* Em mobile ( { :class="['tl-event-pill__status', statusIcon(ev)]" aria-hidden="true" /> + +
{ {{ fmtHora(ev.startH) }} – {{ fmtHora(ev.endH) }}
{{ ev.label }}
+ +
@@ -640,6 +671,46 @@ html:not(.app-dark) .tl-day-badge--feriado { margin-left: 0; } +/* Botao ⏱ "Iniciar cronometro" — overlay no canto sup. direito do + pill em sessoes em curso. Cor solida pra destacar contra o bg + colorido do evento; pulso sutil pra chamar atencao sem irritar. */ +.tl-event-pill__crono, +.vt-event__crono { + position: absolute; + top: 3px; + right: 3px; + width: 22px; + height: 22px; + display: grid; + place-items: center; + background: rgba(0, 0, 0, 0.45); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 9999px; + cursor: pointer; + font-family: inherit; + padding: 0; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35); + transition: background-color 160ms ease, transform 160ms ease, border-color 160ms ease; + animation: tl-crono-pulse 2s ease-in-out infinite; + z-index: 2; +} +.tl-event-pill__crono:hover, +.vt-event__crono:hover { + background: rgba(16, 185, 129, 0.85); /* emerald-500 — convida ao "play" */ + border-color: rgba(255, 255, 255, 0.6); + transform: scale(1.08); + animation-play-state: paused; +} +.tl-event-pill__crono > i, +.vt-event__crono > i { + font-size: 0.7rem; +} +@keyframes tl-crono-pulse { + 0%, 100% { box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35), 0 0 0 0 rgba(16, 185, 129, 0.45); } + 50% { box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35), 0 0 0 6px rgba(16, 185, 129, 0); } +} + /* Realizado: glow verde sutil (cor do bg ja eh verde) — celebra o feito */ .tl-pill--realizado { box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.28), 0 4px 12px rgba(16, 185, 129, 0.18);