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