Layout Melissa (Direção B): preview, /profile, Agenda, dock, cadastro

Sandbox completo do novo layout Win11 lockscreen-style. Não troca o
AppLayout atual — Fase 5 (router wire-up) fica pra sessão dedicada.

Estrutura
- src/layout/melissa/ — MelissaLayout (bg+ψ+overlays), MelissaCronometro,
  MelissaAgenda (fullscreen), MelissaCard, MelissaMenu, MelissaBusca
- composables/useMelissaEventos.js — semana real do FC + range mensal
  pros dots do mini-cal
- composables/useMelissaPacientes.js — agora retorna created_at p/ "novo"
- melissaToques.js — toques Web Audio do término

Rota e persistência
- /preview/melissa (sem auth, sem AppLayout)
- /account/profile ganha 3º card "Melissa" com badge "Em construção"
- bootstrapUserSettings + layout composable aceitam variant='melissa'
- Migration: CHECK constraint user_settings.layout_variant aceita 'melissa'

Light mode
- Gradiente Bloom flipa via CSS vars (--bloom-c1/c2/base-1/base-2)
  Dark: 400/300/950 · Light: 200/100/0
- Cronômetro/Personalização: color: white → var(--m-text)
- Pílula psi-kbd ganha tokens --m-kbd-bg/--m-kbd-text
- Override mapeia text-X-200/300/400 → text-X-600 (17 cores Tailwind)

Agenda fullscreen
- Mini-cal funcional: click pula FC, range visível destacado, dots reais
- Feriados nacional/municipal/personalizado (rose/amber/violet)
- Dias fechados (workRules) cinza apagado, mutex feriado vence
- Card "Hoje" (stats+sessões) mesclado e movido pra sidebar esquerda
- ProximosFeriadosCard reaproveitado entre mini-cal e Hoje
- Avatar paciente: bg --m-accent-strong → --m-accent (saturado em light)
- Cores light: 12 substituições color:white → var(--m-text)

Dock taskbar Win11-style
- .melissa-dock 76px fixed bottom (CSS global, não scoped — Vue static
  hoisting perderia data-v-{hash})
- ψ centralizado vertical na faixa (bottom:10px)
- Chip cronômetro teleportado pro dock + animação minimize macOS
  (dialog encolhe + voa pro canto bottom-left, 340ms cubic-bezier)
- transform-origin: 96px calc(100% - 38px) (posição do chip no dock)

Pacientes na sidebar
- Botão fake "+" no topo abre PatientCreatePopover (rápido/completo/link)
- Reaproveita PatientCadastroDialog + ComponentCadastroRapido
- Pacientes criados nos últimos 7d sobem pro topo + badge "novo"

Dock contextual (ações do paciente selecionado)
- Avatar + nome + count + 5 ações (sessões/whatsapp/prontuário/editar/fechar)
- Teleportado pro .melissa-dock quando há paciente selecionado
- Em mobile, ações vivem em <Menu> kebab por linha
- Pattern <Transition><Teleport v-if> obrigatório (NUNCA o contrário)
  pra evitar comment placeholder + emitsOptions:null no reconciler

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-04-26 08:10:53 -03:00
parent ab103ec88b
commit 1bcb969f72
15 changed files with 6828 additions and 8 deletions
+620
View File
@@ -0,0 +1,620 @@
<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;
} 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>