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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user