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
+3
View File
@@ -27,6 +27,9 @@ evolution-api/
# Backups locais do banco — não comitar (regeneráveis via db.cjs backup)
database-novo/backups/
# Rascunhos de design locais (Melissa Direção A, etc)
layout-scratchs/
# Outputs do Playwright
test-results/
playwright-report/
@@ -0,0 +1,22 @@
-- ==========================================================================
-- Agencia PSI — Migracao: layout_variant aceita 'melissa'
-- ==========================================================================
-- O CHECK constraint user_settings_layout_variant_check restringia o valor
-- a ('classic', 'rail'). Com a chegada do Layout Melissa (Direção B do
-- redesign — wrapper estilo Win11 lockscreen), precisamos aceitar o valor
-- 'melissa' tambem.
--
-- Wire-up real do router (troca do AppLayout pelo MelissaLayout) ainda nao
-- foi feito (Fase 5 do roadmap Melissa) — mas a preferencia ja precisa
-- persistir desde agora pra UI do /profile funcionar.
-- ==========================================================================
ALTER TABLE public.user_settings
DROP CONSTRAINT IF EXISTS user_settings_layout_variant_check;
ALTER TABLE public.user_settings
ADD CONSTRAINT user_settings_layout_variant_check
CHECK (layout_variant = ANY (ARRAY['classic'::text, 'rail'::text, 'melissa'::text]));
COMMENT ON COLUMN public.user_settings.layout_variant
IS 'classic (sidebar) | rail (mini rail + painel) | melissa (Win11 lockscreen, Beta)';
+4 -4
View File
@@ -105,7 +105,7 @@ export async function bootstrapUserSettings({
const _lsV = (() => {
try {
const v = localStorage.getItem('layout_variant');
return v === 'rail' || v === 'classic' ? v : null;
return v === 'rail' || v === 'classic' || v === 'melissa' ? v : null;
} catch {
return null;
}
@@ -114,9 +114,9 @@ export async function bootstrapUserSettings({
if (_lsV !== null) {
// localStorage já tem valor → aplica ele (garante coerência com layoutConfig)
if (_lsV !== (settings.layout_variant ?? _lsV)) setVariant(_lsV);
} else if (settings.layout_variant === 'rail') {
// localStorage vazio + banco tem 'rail' → aplica e grava no localStorage
setVariant('rail');
} else if (settings.layout_variant === 'rail' || settings.layout_variant === 'melissa') {
// localStorage vazio + banco tem 'rail'/'melissa' → aplica e grava no localStorage
setVariant(settings.layout_variant);
}
// localStorage vazio + banco tem 'classic' → mantém padrão 'rail' (não aplica)
+6 -2
View File
@@ -20,10 +20,14 @@ import { computed, reactive, ref, onMounted, onBeforeUnmount } from 'vue';
const BREAKPOINT_XL = 1280;
// ── resolve variant salvo no localStorage ───────────────────
// 'classic' = Sidebar lateral
// 'rail' = Mini rail + painel
// 'melissa' = Layout Melissa (Direção B). Hoje só persiste a preferência —
// o switch real do app vem na Fase 5 (router wire-up).
function _loadVariant() {
try {
const v = localStorage.getItem('layout_variant');
if (v === 'rail' || v === 'classic') return v;
if (v === 'rail' || v === 'classic' || v === 'melissa') return v;
} catch {}
return 'rail';
}
@@ -199,7 +203,7 @@ export function useLayout() {
};
const setVariant = (v, { fromUser = true } = {}) => {
if (v !== 'classic' && v !== 'rail') return;
if (v !== 'classic' && v !== 'rail' && v !== 'melissa') return;
layoutConfig.variant = v;
try {
localStorage.setItem('layout_variant', v);
File diff suppressed because it is too large Load Diff
+427
View File
@@ -0,0 +1,427 @@
<script setup>
/*
* MelissaBusca
* --------------------------------------------------
* Busca rápida glass-style. Adaptação do GlobalSearch da topbar
* (`src/components/search/GlobalSearch.vue`) pro layout Melissa.
*
* Diferenças vs. GlobalSearch:
* - Não chama Supabase — recebe pacientes/eventos via prop (preview)
* - Visual glass (white-on-glass) ao invés de surface-card
* - Sem loading state (busca client-side, instantâneo)
* - Emite ações pro parent decidir o que fazer
*
* Quando promover pra produção: trocar a busca por chamada à RPC
* `search_global` + manter a mesma estrutura de panel/items.
*/
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
const props = defineProps({
pacientes: { type: Array, default: () => [] },
eventos: { type: Array, default: () => [] },
atalhos: {
type: Array,
default: () => [
{ id: 'agenda', label: 'Agenda', icon: 'pi pi-calendar', sublabel: 'Sessões e compromissos', keywords: ['agenda', 'calendario', 'sessoes', 'hoje'] },
{ id: 'pacientes', label: 'Pacientes', icon: 'pi pi-users', sublabel: 'Cadastro e prontuários', keywords: ['pacientes', 'lista', 'cadastro'] },
{ id: 'conversas', label: 'WhatsApp', icon: 'pi pi-whatsapp', sublabel: 'Conversas em andamento', keywords: ['whatsapp', 'conversas', 'mensagens', 'chat'] },
{ id: 'financeiro', label: 'Financeiro', icon: 'pi pi-wallet', sublabel: 'Recebíveis e lançamentos', keywords: ['financeiro', 'pagamentos', 'cobrancas', 'dinheiro'] },
{ id: 'configuracoes', label: 'Configurações', icon: 'pi pi-cog', sublabel: 'Preferências e equipe', keywords: ['configuracoes', 'ajustes', 'preferencias', 'settings'] }
]
}
});
const emit = defineEmits(['acao', 'paciente', 'evento']);
const rootEl = ref(null);
const inputEl = ref(null);
const query = ref('');
const showPanel = ref(false);
const activeIndex = ref(-1);
function normalize(s) {
return String(s || '')
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.trim();
}
function fmtHora(h) {
const horas = Math.floor(h);
const mins = Math.round((h - horas) * 60);
return `${String(horas).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
const filteredAtalhos = computed(() => {
const q = normalize(query.value);
if (!q) return props.atalhos.slice(0, 4); // 4 defaults quando vazio
return props.atalhos.filter((a) => {
const hay = normalize(a.label + ' ' + (a.keywords || []).join(' '));
return hay.includes(q);
}).slice(0, 5);
});
const filteredPacientes = computed(() => {
const q = normalize(query.value);
if (q.length < 2) return [];
return props.pacientes
.filter((p) => normalize(p.nome).includes(q))
.slice(0, 5);
});
const filteredEventos = computed(() => {
const q = normalize(query.value);
if (q.length < 2) return [];
return props.eventos
.filter((e) => {
const hay = normalize(
(e.label || '') + ' ' + (e.pacienteNome || '') + ' ' + (e.descricao || '')
);
return hay.includes(q);
})
.slice(0, 5);
});
const flatList = computed(() => {
const out = [];
filteredAtalhos.value.forEach((a, i) => out.push({ group: 'atalhos', item: a, idx: i }));
filteredPacientes.value.forEach((p, i) => out.push({ group: 'pacientes', item: p, idx: i }));
filteredEventos.value.forEach((e, i) => out.push({ group: 'eventos', item: e, idx: i }));
return out;
});
const hasAnyResult = computed(() => flatList.value.length > 0);
function findFlatIndex(group, idx) {
return flatList.value.findIndex((x) => x.group === group && x.idx === idx);
}
function selectEntry(entry) {
if (entry.group === 'atalhos') emit('acao', entry.item.id);
else if (entry.group === 'pacientes') emit('paciente', entry.item);
else if (entry.group === 'eventos') emit('evento', entry.item);
closePanel();
}
function closePanel() {
showPanel.value = false;
query.value = '';
activeIndex.value = -1;
inputEl.value?.blur();
}
function onFocus() {
showPanel.value = true;
}
function onClickOutside(e) {
if (rootEl.value && !rootEl.value.contains(e.target)) {
showPanel.value = false;
activeIndex.value = -1;
}
}
function onKeydown(e) {
if (!showPanel.value) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex.value = Math.min(activeIndex.value + 1, flatList.value.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex.value = Math.max(activeIndex.value - 1, 0);
} else if (e.key === 'Enter' && activeIndex.value >= 0) {
e.preventDefault();
selectEntry(flatList.value[activeIndex.value]);
} else if (e.key === 'Escape') {
// Stop bubbling pra ESC do parent não fechar overlay aleatório
e.stopPropagation();
closePanel();
}
}
function onGlobalKeydown(e) {
// Ctrl+K / ⌘+K → foca input
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
showPanel.value = true;
inputEl.value?.focus();
}
}
onMounted(() => {
document.addEventListener('mousedown', onClickOutside);
window.addEventListener('keydown', onGlobalKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onClickOutside);
window.removeEventListener('keydown', onGlobalKeydown);
});
</script>
<template>
<div ref="rootEl" class="mb-search">
<div class="mb-field">
<i class="pi pi-search mb-field__icon" />
<input
ref="inputEl"
v-model="query"
type="text"
placeholder="Buscar paciente, agenda, atalho…"
class="mb-field__input"
@focus="onFocus"
@keydown="onKeydown"
/>
<span class="mb-field__kbd" aria-hidden="true">Ctrl K</span>
</div>
<Transition name="mb-fade">
<div v-if="showPanel" class="mb-panel" role="listbox">
<div
v-if="query.trim().length >= 2 && !hasAnyResult"
class="mb-empty"
>
Nada encontrado pra "<span class="text-white/80">{{ query.trim() }}</span>"
</div>
<!-- Atalhos -->
<div v-if="filteredAtalhos.length" class="mb-group">
<div class="mb-group__title">{{ query.trim() ? 'Ações' : 'Atalhos' }}</div>
<button
v-for="(a, i) in filteredAtalhos"
:key="'a-' + a.id"
class="mb-item"
:class="{ 'is-active': findFlatIndex('atalhos', i) === activeIndex }"
@click="selectEntry({ group: 'atalhos', item: a })"
@mouseenter="activeIndex = findFlatIndex('atalhos', i)"
>
<span class="mb-item__icon"><i :class="a.icon" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ a.label }}</span>
<span v-if="a.sublabel" class="mb-item__sub">{{ a.sublabel }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
<!-- Pacientes -->
<div v-if="filteredPacientes.length" class="mb-group">
<div class="mb-group__title">Pacientes</div>
<button
v-for="(p, i) in filteredPacientes"
:key="'p-' + p.id"
class="mb-item"
:class="{ 'is-active': findFlatIndex('pacientes', i) === activeIndex }"
@click="selectEntry({ group: 'pacientes', item: p })"
@mouseenter="activeIndex = findFlatIndex('pacientes', i)"
>
<span class="mb-item__icon"><i class="pi pi-user" /></span>
<span class="mb-item__main">
<span class="mb-item__label">{{ p.nome }}</span>
<span class="mb-item__sub">Abrir prontuário</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
<!-- Eventos -->
<div v-if="filteredEventos.length" class="mb-group">
<div class="mb-group__title">Agenda de hoje</div>
<button
v-for="(e, i) in filteredEventos"
:key="'e-' + e.id"
class="mb-item"
:class="{ 'is-active': findFlatIndex('eventos', i) === activeIndex }"
@click="selectEntry({ group: 'eventos', item: e })"
@mouseenter="activeIndex = findFlatIndex('eventos', i)"
>
<span
class="mb-item__icon"
:style="{ backgroundColor: `${e.color}33`, color: e.color }"
>
<i class="pi pi-clock" />
</span>
<span class="mb-item__main">
<span class="mb-item__label">{{ e.label }}</span>
<span class="mb-item__sub">{{ fmtHora(e.startH) }} {{ fmtHora(e.endH) }}</span>
</span>
<i class="mb-item__go pi pi-arrow-right" />
</button>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.mb-search {
position: relative;
width: 100%;
max-width: 560px;
/* Só horizontal — top/bottom ficam livres pro parent (mt-X do Tailwind) */
margin-left: auto;
margin-right: auto;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
.mb-field {
position: relative;
display: flex;
align-items: center;
background: var(--m-bg-soft);
backdrop-filter: blur(20px) saturate(140%);
-webkit-backdrop-filter: blur(20px) saturate(140%);
border: 1px solid var(--m-border-strong);
border-radius: 12px;
padding: 0 14px;
height: 44px;
transition: background-color 160ms ease, border-color 160ms ease;
}
.mb-field:focus-within {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mb-field__icon {
color: var(--m-text-muted);
font-size: 0.95rem;
margin-right: 10px;
flex-shrink: 0;
}
.mb-field__input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: white;
font-size: 0.9rem;
font-family: inherit;
min-width: 0;
}
.mb-field__input::placeholder {
color: var(--m-text-muted);
}
.mb-field__kbd {
color: var(--m-text-muted);
font-size: 0.62rem;
font-weight: 500;
padding: 2px 7px;
border-radius: 4px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border-strong);
margin-left: 8px;
flex-shrink: 0;
letter-spacing: 0.05em;
}
.mb-panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 30;
max-height: 60vh;
overflow-y: auto;
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border: 1px solid var(--m-border-strong);
border-radius: 12px;
padding: 6px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mb-panel::-webkit-scrollbar { width: 6px; }
.mb-panel::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mb-empty {
padding: 18px 14px;
text-align: center;
color: var(--m-text-muted);
font-size: 0.85rem;
}
.mb-group + .mb-group {
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--m-border);
}
.mb-group__title {
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--m-text-faint);
font-size: 0.6rem;
font-weight: 600;
padding: 8px 10px 4px;
}
.mb-item {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: transparent;
border: none;
border-radius: 8px;
color: white;
text-align: left;
cursor: pointer;
font-family: inherit;
transition: background-color 100ms ease;
}
.mb-item:hover,
.mb-item.is-active {
background: var(--m-bg-soft);
}
.mb-item__icon {
width: 30px;
height: 30px;
display: grid;
place-items: center;
background: var(--m-bg-soft);
border-radius: 7px;
color: var(--m-text-muted);
flex-shrink: 0;
font-size: 0.85rem;
}
.mb-item__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.mb-item__label {
font-size: 0.85rem;
color: white;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mb-item__sub {
font-size: 0.7rem;
color: var(--m-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mb-item__go {
color: var(--m-text-faint);
font-size: 0.7rem;
flex-shrink: 0;
}
.mb-fade-enter-active,
.mb-fade-leave-active {
transition: opacity 140ms ease, transform 160ms ease;
}
.mb-fade-enter-from,
.mb-fade-leave-to {
opacity: 0;
transform: translateY(-6px);
}
</style>
+175
View File
@@ -0,0 +1,175 @@
<script setup>
/*
* MelissaCard
* --------------------------------------------------
* Card glass do dashboard Melissa.
*
* Dois variants:
* - default: header (ícone+título+badge opcional) + slot de conteúdo
* + botão redondo "+" centralizado na borda inferior que
* emite `open` (parent decide o que fazer)
* - add: placeholder tracejado, meia largura, com "+" centralizado;
* emite `add` ao clicar
*
* Largura é fixa (não estica) — o layout flex no parent define o número
* de cards visíveis conforme o tamanho da tela.
*/
defineProps({
variant: {
type: String,
default: 'default',
validator: (v) => ['default', 'add'].includes(v)
},
icon: { type: String, default: '' }, // ex.: 'pi pi-user'
iconColor: { type: String, default: '' }, // classe Tailwind ex.: 'text-emerald-300'
title: { type: String, default: '' },
badge: { type: [String, Number, null], default: null },
badgeColor: { type: String, default: 'bg-red-500/80' }, // classe Tailwind
actionTitle: { type: String, default: 'Abrir' }
});
defineEmits(['open', 'add']);
</script>
<template>
<article v-if="variant === 'default'" class="mc-card">
<div class="mc-card__head">
<span v-if="icon" class="mc-card__icon">
<i :class="[icon, iconColor]" />
</span>
<span class="mc-card__title">{{ title }}</span>
<span v-if="badge !== null && badge !== ''" class="mc-card__badge" :class="badgeColor">
{{ badge }}
</span>
</div>
<div class="mc-card__body">
<slot />
</div>
<button class="mc-card__go" :title="actionTitle" @click="$emit('open')">
<i class="pi pi-plus text-xs" />
</button>
</article>
<button v-else class="mc-card-add" :title="actionTitle" @click="$emit('add')">
<i class="pi pi-plus" />
</button>
</template>
<style scoped>
/* ─── Card padrão ──────────────────────────────────────────── */
.mc-card {
position: relative; /* ancora o botão "+" */
flex-shrink: 0;
width: 210px;
background: var(--m-bg-soft);
backdrop-filter: blur(20px) saturate(140%);
-webkit-backdrop-filter: blur(20px) saturate(140%);
border: 1px solid var(--m-border);
border-radius: 14px;
padding: 14px 16px;
transition: background-color 160ms ease, transform 160ms ease;
}
.mc-card:hover {
background: var(--m-bg-soft-hover);
transform: translateY(-2px);
}
.mc-card__head {
display: flex;
align-items: center;
gap: 10px;
color: var(--m-text);
font-size: 0.8rem;
font-weight: 500;
letter-spacing: 0.01em;
}
.mc-card__icon {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 12px;
background: var(--m-bg-soft-hover);
backdrop-filter: blur(16px) saturate(160%);
-webkit-backdrop-filter: blur(16px) saturate(160%);
border: 1px solid var(--m-border-strong);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
flex-shrink: 0;
}
.mc-card__title {
/* deixa o título absorver o espaço entre ícone e badge */
flex: 1;
min-width: 0;
}
.mc-card__badge {
/* margin-left: auto não precisa mais (title flex:1 empurra o badge) */
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 9999px;
color: white;
font-weight: 600;
flex-shrink: 0;
}
.mc-card__body {
margin-top: 12px;
color: white;
}
/* Botão "+" centralizado na borda inferior do card */
.mc-card__go {
position: absolute;
bottom: -16px;
left: 50%;
transform: translateX(-50%);
width: 32px;
height: 32px;
border-radius: 12px;
background: rgba(30, 30, 45, 0.85);
backdrop-filter: blur(20px) saturate(160%);
-webkit-backdrop-filter: blur(20px) saturate(160%);
border: 1px solid var(--m-border-strong);
color: white;
display: grid;
place-items: center;
cursor: pointer;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35);
transition: background-color 180ms ease, transform 180ms ease, border-color 180ms ease;
z-index: 5;
}
.mc-card__go:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
transform: translateX(-50%) scale(1.08);
}
.mc-card__go:active {
transform: translateX(-50%) scale(0.96);
}
/* ─── Card "Adicionar" (tracejado, meia largura) ──────────── */
/* Sem align-self aqui — o parent (cards-shell) decide se estica
(linha única) ou mantém intrínseco (wrap). */
.mc-card-add {
flex-shrink: 0;
width: 130px; /* metade do card padrão */
min-height: 100px;
border: 1.5px dashed var(--m-border-strong);
border-radius: 14px;
background: var(--m-bg-soft);
color: var(--m-text-muted);
display: grid;
place-items: center;
cursor: pointer;
font-size: 1.5rem;
transition: all 200ms ease;
}
.mc-card-add:hover {
background: var(--m-bg-soft);
border-color: var(--m-text-muted);
color: white;
transform: translateY(-2px);
}
.mc-card-add:active {
transform: translateY(0);
}
</style>
+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>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
/*
* useMelissaEventos — composables que carregam eventos reais da agenda
* --------------------------------------------------
* Pattern espelhado de `src/features/agenda/pages/AgendaTerapeutaPage.vue`
* (loadMonthSearchRows ~ linha 539).
*
* Exporta dois composables:
* - useMelissaEventosSemana(refDateRef) — semana de refDate (ref<Date>)
* - useMelissaEventosHoje() — apenas o dia atual
*
* Forma normalizada do evento:
* {
* id, tipo, status, titulo,
* pacienteNome, modalidade, descricao,
* color, label,
* inicio_em, fim_em,
* startH, endH, // decimais (9.5 = 09:30) — usado pelo layout
* dateKey // 'YYYY-MM-DD' pra agrupar por dia
* }
*
* Sem auth/tenant: retorna [] silencioso (UI segue funcional).
*
* NÃO inclui ocorrências virtuais de recorrência (loadAndExpand) pra simplificar
* o preview. Adicionar quando promover Melissa pra produção.
*/
import { ref, watch, onMounted, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
// ── Cores por tipo/status (consistente com o resto do Melissa) ──
function pickColor(tipo, status) {
const s = String(status || '').toLowerCase();
if (s === 'realizado' || s === 'realizada') return '#10b981';
if (s === 'faltou') return '#ef4444';
if (s === 'cancelado' || s === 'cancelada') return '#94a3b8';
const t = String(tipo || '').toLowerCase();
if (t === 'supervisao' || t === 'supervisão') return '#a855f7';
if (t === 'reuniao' || t === 'reunião') return '#0ea5e9';
if (t === 'bloqueio') return '#64748b';
return '#6366f1'; // sessao default
}
function isoToDecimalHour(iso) {
const d = new Date(iso);
return d.getHours() + d.getMinutes() / 60;
}
function normalizeEvent(r) {
const pacNome = r.patients?.nome_completo || '';
return {
id: r.id,
tipo: r.tipo || 'sessao',
status: r.status || '',
titulo: r.titulo || '',
pacienteNome: pacNome,
modalidade: r.modalidade || '',
descricao: r.observacoes || '',
color: pickColor(r.tipo, r.status),
label: pacNome || r.titulo || '—',
inicio_em: r.inicio_em,
fim_em: r.fim_em,
startH: isoToDecimalHour(r.inicio_em),
endH: isoToDecimalHour(r.fim_em),
dateKey: String(r.inicio_em || '').slice(0, 10)
};
}
// ── Helper interno: garante uid + tenant + faz a query ──
async function _fetchRange(start, end) {
const tenantStore = useTenantStore();
const { data: userData } = await supabase.auth.getUser();
const userId = userData?.user?.id || null;
if (typeof tenantStore.ensureLoaded === 'function') {
await tenantStore.ensureLoaded();
}
const tid = tenantStore.activeTenantId || tenantStore.tenantId || null;
if (!userId || !tid) return [];
const { data, error } = await supabase
.from('agenda_eventos')
.select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
.eq('owner_id', userId)
.is('mirror_of_event_id', null)
.gte('inicio_em', start.toISOString())
.lt('inicio_em', end.toISOString())
.order('inicio_em', { ascending: true });
if (error) throw error;
return (data || []).map(normalizeEvent);
}
// ── Range helpers ──────────────────────────────────────────────
function rangeSemana(refDate) {
const ref = new Date(refDate);
const dow = ref.getDay(); // 0=dom, 1=seg
const diff = dow === 0 ? -6 : 1 - dow;
const segunda = new Date(ref);
segunda.setDate(ref.getDate() + diff);
segunda.setHours(0, 0, 0, 0);
// domingo final → usa dia seguinte 00:00 com `lt` pra incluir tudo até 23:59:59.999
const apósDomingo = new Date(segunda);
apósDomingo.setDate(segunda.getDate() + 7);
return { start: segunda, end: apósDomingo };
}
function rangeHoje() {
const inicio = new Date();
inicio.setHours(0, 0, 0, 0);
const fim = new Date(inicio);
fim.setDate(inicio.getDate() + 1); // amanhã 00:00
return { start: inicio, end: fim };
}
// ── COMPOSABLE 1: semana visível (MelissaAgenda) ──────────────
export function useMelissaEventosSemana(refDateRef) {
const eventos = ref([]);
const loading = ref(false);
const error = ref(null);
async function fetch() {
loading.value = true;
error.value = null;
try {
const { start, end } = rangeSemana(refDateRef.value);
eventos.value = await _fetchRange(start, end);
} catch (e) {
error.value = e?.message || 'Erro ao carregar agenda';
eventos.value = [];
// eslint-disable-next-line no-console
console.warn('[useMelissaEventosSemana]', e);
} finally {
loading.value = false;
}
}
onMounted(fetch);
watch(refDateRef, fetch);
// Helper computado: agrupa por dateKey ('YYYY-MM-DD')
const eventosPorDia = computed(() => {
const map = {};
for (const ev of eventos.value) {
(map[ev.dateKey] ||= []).push(ev);
}
return map;
});
return { eventos, eventosPorDia, loading, error, refetch: fetch };
}
// ── COMPOSABLE 3: range arbitrário (FullCalendar passa via datesSet) ──
// Usado pelo MelissaAgenda — refetcha sempre que start/end mudam (mudança
// de view, navegação prev/next/today). Cobre Day/Week/Month/List sem custo.
export function useMelissaEventosRange(startRef, endRef) {
const eventos = ref([]);
const loading = ref(false);
const error = ref(null);
async function fetch() {
const s = startRef.value;
const e = endRef.value;
if (!s || !e) { eventos.value = []; return; }
loading.value = true;
error.value = null;
try {
eventos.value = await _fetchRange(new Date(s), new Date(e));
} catch (err) {
error.value = err?.message || 'Erro ao carregar agenda';
eventos.value = [];
// eslint-disable-next-line no-console
console.warn('[useMelissaEventosRange]', err);
} finally {
loading.value = false;
}
}
onMounted(fetch);
watch([startRef, endRef], fetch);
return { eventos, loading, error, refetch: fetch };
}
// ── COMPOSABLE 2: apenas hoje (MelissaLayout) ──────────────────
export function useMelissaEventosHoje() {
const eventos = ref([]);
const loading = ref(false);
const error = ref(null);
async function fetch() {
loading.value = true;
error.value = null;
try {
const { start, end } = rangeHoje();
eventos.value = await _fetchRange(start, end);
} catch (e) {
error.value = e?.message || 'Erro ao carregar agenda';
eventos.value = [];
// eslint-disable-next-line no-console
console.warn('[useMelissaEventosHoje]', e);
} finally {
loading.value = false;
}
}
onMounted(fetch);
return { eventos, loading, error, refetch: fetch };
}
@@ -0,0 +1,107 @@
/*
* useMelissaPacientes — composable que carrega pacientes ativos do terapeuta
* --------------------------------------------------
* Pattern espelhado de `src/features/patients/PatientsListPage.vue`:
* - withOwnerFilter (apenas os pacientes do owner_id = uid do user logado)
* - withTenantFilter (defesa em profundidade — RLS cobre, mas blindamos no client)
* - normalizeStatus client-side (DB pode ter 'ativo'/'active'/null/etc.)
*
* Sem auth (sem uid ou tenant), retorna vazio sem erro — uso em /preview/melissa
* permite a página renderizar mesmo sem session.
*
* Forma normalizada do paciente:
* { id, nome, email, telefone, avatar_url, status, last_attended_at, created_at }
*
* Quando precisar fora do Melissa: promover pra src/composables/.
*/
import { ref, onMounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
// Mesma lógica da PatientsListPage.vue — DB pode ter valores variados
function normalizeStatus(s) {
const v = String(s || '').toLowerCase().trim();
if (!v) return 'Ativo';
if (v === 'active' || v === 'ativo') return 'Ativo';
if (v === 'inactive' || v === 'inativo') return 'Inativo';
return v.charAt(0).toUpperCase() + v.slice(1);
}
export function useMelissaPacientes() {
const tenantStore = useTenantStore();
const pacientes = ref([]);
const loading = ref(false);
const error = ref(null);
const uid = ref(null);
async function ensureUid() {
if (uid.value) return uid.value;
const { data, error: err } = await supabase.auth.getUser();
if (err) return null;
uid.value = data?.user?.id || null;
return uid.value;
}
async function fetchPacientes() {
const userId = await ensureUid();
// Garante que o tenantStore foi hidratado (preview misc não passa por
// guard de auth, então o store pode estar vazio mesmo com user logado)
if (typeof tenantStore.ensureLoaded === 'function') {
await tenantStore.ensureLoaded();
}
const tid = tenantStore.activeTenantId || tenantStore.tenantId || null;
if (!userId || !tid) {
pacientes.value = [];
return;
}
loading.value = true;
error.value = null;
try {
// Não filtra status no SQL — DB tem valores inconsistentes
// ('ativo', 'Ativo', 'active', null...). Normaliza e filtra no client.
const { data, error: err } = await supabase
.from('patients')
.select('id, nome_completo, email_principal, telefone, status, avatar_url, last_attended_at, created_at')
.eq('owner_id', userId)
.eq('tenant_id', tid)
.order('nome_completo', { ascending: true })
.limit(1000);
if (err) throw err;
const todos = (data || []).map((r) => ({
id: r.id,
nome: r.nome_completo || '',
email: r.email_principal || '',
telefone: r.telefone || '',
avatar_url: r.avatar_url || null,
status: normalizeStatus(r.status),
last_attended_at: r.last_attended_at || null,
created_at: r.created_at || null
}));
pacientes.value = todos.filter((p) => p.status === 'Ativo');
} catch (e) {
error.value = e?.message || 'Erro ao carregar pacientes';
pacientes.value = [];
// eslint-disable-next-line no-console
console.warn('[useMelissaPacientes]', e);
} finally {
loading.value = false;
}
}
onMounted(fetchPacientes);
return {
pacientes,
loading,
error,
refetch: fetchPacientes
};
}
+116
View File
@@ -0,0 +1,116 @@
/*
* melissaToques — geração de toques de término via Web Audio API
* --------------------------------------------------------------
* Não usa arquivos de áudio externos. Tudo gerado em runtime com
* osciladores. Mantém self-hosted, leve e sem build de assets.
*
* Uso:
* import { TOQUES, playToque } from './melissaToques';
* playToque('sino');
*/
export const TOQUES = [
{ id: 'sino', label: 'Sino' },
{ id: 'acorde', label: 'Acorde' },
{ id: 'tic-tac', label: 'Tic-tac' },
{ id: 'suave', label: 'Suave' },
{ id: 'nenhum', label: 'Nenhum (silencioso)' }
];
function getCtx() {
const Ctx = window.AudioContext || window.webkitAudioContext;
return Ctx ? new Ctx() : null;
}
// Sino: clássico ding com decaimento longo
function playSino(ctx) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'sine';
osc.frequency.value = 880; // A5
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.25, ctx.currentTime + 0.01);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 1.6);
osc.start();
osc.stop(ctx.currentTime + 1.7);
setTimeout(() => ctx.close(), 1900);
}
// Acorde: C maior arpejado (C5 E5 G5)
function playAcorde(ctx) {
const freqs = [523.25, 659.25, 783.99];
freqs.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'sine';
osc.frequency.value = freq;
const start = ctx.currentTime + i * 0.07;
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(0.18, start + 0.01);
gain.gain.exponentialRampToValueAtTime(0.001, start + 1.5);
osc.start(start);
osc.stop(start + 1.6);
});
setTimeout(() => ctx.close(), 2100);
}
// Tic-tac: dois cliques curtos e secos
function playTicTac(ctx) {
[0, 0.18].forEach((delay) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'square';
osc.frequency.value = 1500;
const start = ctx.currentTime + delay;
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(0.12, start + 0.005);
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.06);
osc.start(start);
osc.stop(start + 0.07);
});
setTimeout(() => ctx.close(), 500);
}
// Suave: fade-in/fade-out lento, quase respiração
function playSuave(ctx) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = 'sine';
osc.frequency.value = 660; // E5
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.18, ctx.currentTime + 0.5);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 2.5);
osc.start();
osc.stop(ctx.currentTime + 2.6);
setTimeout(() => ctx.close(), 2800);
}
const PLAYERS = {
sino: playSino,
acorde: playAcorde,
'tic-tac': playTicTac,
suave: playSuave,
nenhum: () => {}
};
export function playToque(id) {
if (id === 'nenhum') return;
const fn = PLAYERS[id] || PLAYERS.sino;
try {
const ctx = getCtx();
if (!ctx) return;
// Web Audio em alguns browsers começa suspended até primeira interação
if (ctx.state === 'suspended') ctx.resume?.();
fn(ctx);
} catch {
// Falha silenciosa: não há nada útil a fazer aqui
}
}
+8
View File
@@ -23,6 +23,14 @@ export default {
component: () => import('@/views/pages/Landing.vue')
},
// Sandbox do layout Melissa (Direção B — lockscreen-style)
// Standalone, sem auth, sem AppLayout. Promovido de /preview/dashboard-win11.
{
path: 'preview/melissa',
name: 'PreviewMelissa',
component: () => import('@/layout/melissa/MelissaLayout.vue')
},
// 404
{
path: 'pages/notfound',
+118 -2
View File
@@ -1366,7 +1366,7 @@ onBeforeUnmount(() => {
<div class="h-px bg-[var(--surface-border)] my-5" />
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Layout 1: Clássico -->
<button
class="lv-card"
@@ -1423,12 +1423,44 @@ onBeforeUnmount(() => {
</div>
</div>
</button>
<!-- Layout 3: Melissa (Direção B) Em construção -->
<button
class="lv-card lv-card--wip"
:class="{ 'lv-card--active': layoutConfig.variant === 'melissa' }"
@click="
setVariant('melissa');
markDirty();
"
>
<span class="lv-card__badge">Em construção</span>
<div class="lv-card__preview lv-card__preview--melissa">
<div class="lv-pm__clock">12<span class="lv-pm__sep">:</span>30</div>
<div class="lv-pm__cards">
<div class="lv-pm__card" />
<div class="lv-pm__card" />
<div class="lv-pm__card" />
</div>
<div class="lv-pm__psi">ψ</div>
</div>
<div class="lv-card__foot">
<div class="lv-card__radio">
<div v-if="layoutConfig.variant === 'melissa'" class="lv-card__dot" />
</div>
<div>
<div class="lv-card__name">Melissa</div>
<div class="lv-card__sub">Tela cheia estilo Win11 lockscreen</div>
</div>
</div>
</button>
</div>
<div class="mt-4 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] px-4 py-3 flex items-start gap-3">
<i class="pi pi-info-circle text-[var(--text-color-secondary)] mt-0.5 shrink-0" />
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
O layout Rail exibe ícones no canto esquerdo. Ao clicar em um ícone, o painel lateral expande com os itens de navegação. Disponível apenas no desktop.
<strong>Rail:</strong> ícones no canto esquerdo + painel expansível. Disponível apenas no desktop.<br />
<strong>Melissa</strong> <span class="text-[0.85rem] uppercase tracking-wider font-semibold" style="color: var(--primary-color)"> em construção:</span> layout fullscreen com resumo do dia, busca rápida e cronômetro de sessão. Selecionar aqui salva sua preferência, mas a navegação completa ainda não está integrada. Acesse o preview em
<a href="/preview/melissa" target="_blank" rel="noopener" class="underline hover:text-[var(--text-color)]">/preview/melissa</a>.
</div>
</div>
</div>
@@ -1702,6 +1734,90 @@ onBeforeUnmount(() => {
margin-top: 1px;
}
/* ─── Card Melissa (Direção B) — preview Win11 lockscreen ──── */
.lv-card--wip {
/* Listras suaves no fundo do card pra reforçar visualmente "em obras",
sem prejudicar o preview no topo. */
background-image: repeating-linear-gradient(
135deg,
transparent 0,
transparent 12px,
color-mix(in srgb, var(--primary-color) 4%, transparent) 12px,
color-mix(in srgb, var(--primary-color) 4%, transparent) 14px
);
}
.lv-card__badge {
position: absolute;
top: 8px;
right: 8px;
z-index: 2;
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 3px 9px;
border-radius: 999px;
background: color-mix(in srgb, var(--primary-color) 22%, transparent);
color: var(--primary-color);
border: 1px solid color-mix(in srgb, var(--primary-color) 40%, transparent);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.lv-card__preview--melissa {
position: relative;
background:
radial-gradient(circle at 70% 30%, color-mix(in srgb, var(--primary-color) 35%, transparent) 0%, transparent 55%),
radial-gradient(circle at 25% 75%, color-mix(in srgb, var(--primary-color) 22%, transparent) 0%, transparent 50%),
linear-gradient(135deg, var(--surface-900, #0f172a) 0%, color-mix(in srgb, var(--primary-color) 50%, var(--surface-900, #0f172a)) 50%, var(--surface-900, #0f172a) 100%);
padding: 0;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 4px;
}
.lv-pm__clock {
font-family: 'Segoe UI', system-ui, sans-serif;
font-size: 1.4rem;
font-weight: 200;
color: rgba(255, 255, 255, 0.95);
letter-spacing: -0.04em;
line-height: 1;
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.lv-pm__sep {
opacity: 0.6;
}
.lv-pm__cards {
display: flex;
gap: 4px;
margin-top: 2px;
}
.lv-pm__card {
width: 14px;
height: 10px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.18);
border: 1px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(2px);
}
.lv-pm__psi {
position: absolute;
bottom: 6px;
left: 8px;
width: 14px;
height: 14px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.25);
color: rgba(255, 255, 255, 0.95);
font-family: 'Instrument Serif', Georgia, serif;
font-size: 0.7rem;
font-style: italic;
display: grid;
place-items: center;
line-height: 1;
}
/* ─── Animation ─────────────────────────────────────────── */
@keyframes prof-fadeUp {
from {