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
@@ -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
};
}