melissa/cronometro: pre-selecionar paciente + sessionPlan + confirm fechar

MelissaCronometro.abrir() agora aceita opts { pacienteId, autostart,
sessionPlan }. Retorna { opened, alreadyRunning, samePaciente, ... }
pra caller decidir o feedback. Estado sessionPlan { startH, endH }
exibe "Programado: HH:MM – HH:MM" sob o select + badge laranja
"atrasada Xmin" quando hNow > startH. Cronometro NAO auto-ajusta —
analista decide quando comecar/parar. Tick a 30s atualiza atraso.
sessionPlan persiste no localStorage junto com o snapshot.

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

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

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

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