Files
agenciapsilmno/src/layout/melissa/MelissaLayout.vue
T
Leonardo 06bce11e1c Melissa: deep-link via URL + Pacientes (WIP) + cronometro reset
Roteamento por URL (substitui o ref local secaoAberta):
- routes.misc.js: rota vira /preview/melissa/:secao? — param opcional
- MelissaLayout.vue: secaoAberta agora e computed do route.params.secao,
  validado contra SECOES (chave invalida -> null). abrirSecao/fecharSecao
  fazem router.push em vez de mutar ref. Habilita back/forward, refresh
  e deep-link tipo /preview/melissa/agenda.

Pagina Pacientes (WIP, ainda nao wireada no slot do Layout):
- src/layout/melissa/MelissaPacientes.vue (novo, ~? linhas) — fullscreen
  3-col espelhando MelissaAgenda: aside esquerda com filtros (status /
  grupos / tags), lista central com cards + busca, quick view direita
  com KPIs do paciente selecionado + acoes.
- Carrega pacientes (todos os status), grupos/tags do tenant, vinculos
  patient_groups + patient_tags + session counts em paralelo.
- Integra PatientProntuario (overlay), PatientCadastroDialog,
  PatientCreatePopover + ComponentCadastroRapido, e
  conversationDrawerStore (acao WhatsApp da quick view).

useMelissaPacientes ganha opcao { onlyActive }:
- default true (compat com cards do resumo / cronometro / eventos hoje
  — so faz sentido com ativos)
- false retorna Ativo + Inativo + Arquivado, pra uso na pagina nova
- select agora inclui data_nascimento (necessario pros KPIs da quick view)

Cronometro: zera ao parar — terminou a sessao, fica pronto pra proxima
sem precisar reabrir o popover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:12:15 -03:00

2410 lines
100 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
/*
* MelissaLayout — Layout "lockscreen" (Direção B do redesign)
* --------------------------------------------------
* Tela-resumo (zen, info passiva) com botão único no canto inferior
* esquerdo que sobe um workspace por cima (com blur+dim no fundo).
*
* Status atual: arquivo único contendo TODA a experiência Melissa
* (background + resumo + overlays). Em fases futuras será dividido em:
* - MelissaLayout.vue (wrapper: bg + dim + ψ + overlays + slot)
* - MelissaResumo.vue (conteúdo do resumo: clock, greeting, timeline, cards)
*
* Dados ainda em mock. Próxima fase pluga Supabase.
*
* Rota atual (sandbox): /preview/melissa
*/
import { ref, computed, watch, onMounted, onBeforeUnmount, provide } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { useLayout } from '@/layout/composables/layout';
import { applyThemeEngine } from '@/theme/theme.options';
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence';
import MelissaCronometro from './MelissaCronometro.vue';
import MelissaCard from './MelissaCard.vue';
import MelissaBusca from './MelissaBusca.vue';
import MelissaMenu from './MelissaMenu.vue';
import MelissaAgenda from './MelissaAgenda.vue';
import MelissaPacientes from './MelissaPacientes.vue';
import MelissaEventoPanel from './MelissaEventoPanel.vue';
import { TOQUES, playToque } from './melissaToques';
import { useMelissaPacientes } from './composables/useMelissaPacientes';
import { useMelissaEventosHoje } from './composables/useMelissaEventos';
import { useMelissaAgenda, MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
import { supabase } from '@/lib/supabase/client';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import ConversationDrawer from '@/components/conversations/ConversationDrawer.vue';
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue';
import BloqueioDialog from '@/features/agenda/components/BloqueioDialog.vue';
// Pacientes ativos do tenant (real, via Supabase)
const { pacientes: pacientesReais, refetch: refetchPacientes } = useMelissaPacientes();
// Eventos reais de hoje — alimenta timeline + cards + busca + "Hoje há"
const { eventos: eventosHojeReais } = useMelissaEventosHoje();
// ───────────────────────────────────────────────────────────────
// Catálogo de cards do resumo (extensível — novos cards entram aqui)
// ───────────────────────────────────────────────────────────────
const CARDS_CATALOG = [
{ id: 'proximo-paciente', label: 'Próximo paciente', icon: 'pi pi-user', implementado: true },
{ id: 'whatsapp', label: 'WhatsApp', icon: 'pi pi-whatsapp', implementado: true },
{ id: 'recebiveis', label: 'Recebíveis hoje', icon: 'pi pi-wallet', implementado: true },
{ id: 'copilot', label: 'Sugestão Copilot', icon: 'pi pi-sparkles', implementado: true },
{ id: 'agenda-amanha', label: 'Agenda de amanhã', icon: 'pi pi-calendar', implementado: false },
{ id: 'aniversariantes', label: 'Aniversariantes', icon: 'pi pi-gift', implementado: false },
{ id: 'faltas-recentes', label: 'Faltas recentes', icon: 'pi pi-exclamation-circle', implementado: false },
{ id: 'tempo-resposta', label: 'Tempo médio de resposta', icon: 'pi pi-stopwatch', implementado: false }
];
const CARD_IDS = new Set(CARDS_CATALOG.map((c) => c.id));
// User config — quais cards estão ativos e em que ordem; modo de visualização
const cardsAtivos = ref(['proximo-paciente', 'whatsapp', 'recebiveis', 'copilot']);
const cardsLayout = ref('linha-unica'); // 'linha-unica' | 'duas-linhas'
const centralOpen = ref(false);
function toggleCardAtivo(id) {
const idx = cardsAtivos.value.indexOf(id);
if (idx >= 0) cardsAtivos.value.splice(idx, 1);
else cardsAtivos.value.push(id);
}
function getCatalogItem(id) {
return CARDS_CATALOG.find((c) => c.id === id);
}
// ───────────────────────────────────────────────────────────────
// Seções (overlay sobre o resumo — não saem do layout Melissa)
// ───────────────────────────────────────────────────────────────
const SECOES = {
agenda: { label: 'Agenda', icon: 'pi pi-calendar', descricao: 'Sessões agendadas, recorrências, compromissos especiais.' },
pacientes: { label: 'Pacientes', icon: 'pi pi-users', descricao: 'Cadastro, grupos, tags, prontuários e histórico.' },
conversas: { label: 'WhatsApp', icon: 'pi pi-whatsapp', descricao: 'Conversas em andamento, templates, automações e status.' },
prontuarios: { label: 'Prontuários', icon: 'pi pi-file', descricao: 'Documentos clínicos, modelos e histórico por paciente.' },
financeiro: { label: 'Financeiro', icon: 'pi pi-wallet', descricao: 'Recebíveis, lançamentos, relatórios e conciliação.' },
configuracoes: { label: 'Configurações', icon: 'pi pi-cog', descricao: 'Preferências, integrações e equipe.' },
copilot: { label: 'Copilot', icon: 'pi pi-sparkles', descricao: 'Insights da IA, sugestões e automações inteligentes.' },
// Sub-itens da categoria "Agenda e Pacientes" do menu
'cadastros-recebidos': { label: 'Cadastros recebidos', icon: 'pi pi-inbox', descricao: 'Solicitações vindas do agendador online.' },
recorrencias: { label: 'Recorrências', icon: 'pi pi-sync', descricao: 'Sessões recorrentes e séries semanais.' },
compromissos: { label: 'Compromissos determinados', icon: 'pi pi-flag', descricao: 'Compromissos especiais com janelas próprias.' },
grupos: { label: 'Grupos de pacientes', icon: 'pi pi-th-large', descricao: 'Categorize pacientes por grupos.' },
tags: { label: 'Tags', icon: 'pi pi-tag', descricao: 'Etiquetas livres pra organizar pacientes.' },
medicos: { label: 'Médicos e referências', icon: 'pi pi-user-edit', descricao: 'Profissionais que encaminham ou recebem pacientes.' }
};
// Seção ativa = param `:secao?` da rota. URL é a fonte da verdade pra
// permitir back/forward e deep-link (ex: /preview/melissa/agenda abre o
// overlay da agenda direto). Se a chave for inválida, vira null.
const router = useRouter();
const route = useRoute();
const secaoAberta = computed(() => {
const s = route.params?.secao;
return s && SECOES[s] ? s : null;
});
function abrirSecao(key) {
// Fecha overlays paralelos pra evitar empilhamento
workspaceOpen.value = false;
eventoSelecionado.value = null;
if (key === secaoAberta.value) return; // no-op, evita push duplicado
router.push({ name: 'PreviewMelissa', params: { secao: key } });
}
function fecharSecao() {
if (!secaoAberta.value) return;
router.push({ name: 'PreviewMelissa', params: {} });
}
// Prefs de layout/UI (toque, fundo, opacidade, formato hora)
// TODO: migrar pra configs do tenant — hoje só localStorage pra survive refresh
const LAYOUT_STORAGE_KEY = 'melissa.layout.v1';
const TOQUE_IDS = new Set(TOQUES.map((t) => t.id));
// ───────────────────────────────────────────────────────────────
// Relógio (atualiza a cada segundo)
// ───────────────────────────────────────────────────────────────
const now = ref(new Date());
let clockTimer = null;
onMounted(() => {
clockTimer = setInterval(() => (now.value = new Date()), 1000);
window.addEventListener('keydown', onKeydown);
});
onBeforeUnmount(() => {
if (clockTimer) clearInterval(clockTimer);
window.removeEventListener('keydown', onKeydown);
});
const use24h = ref(true);
const horaFormatada = computed(() => {
const d = now.value;
if (use24h.value) {
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
let h = d.getHours();
const ampm = h >= 12 ? 'PM' : 'AM';
h = h % 12 || 12;
return `${h}:${String(d.getMinutes()).padStart(2, '0')} ${ampm}`;
});
const dataExtenso = computed(() => {
return now.value.toLocaleDateString('pt-BR', {
weekday: 'long',
day: 'numeric',
month: 'long'
});
});
const saudacao = computed(() => {
const h = now.value.getHours();
if (h >= 5 && h < 12) return 'Bom dia';
if (h >= 12 && h < 18) return 'Boa tarde';
return 'Boa noite';
});
// ───────────────────────────────────────────────────────────────
// Background customizável
// ───────────────────────────────────────────────────────────────
const bgUrl = ref(''); // vazio = usa gradiente default
const overlayOpacity = ref(0.35); // 00.8 — escurecedor sobre o bg
const bgImageOpacity = ref(1); // 0.011 — transparência da foto custom
const fileInput = ref(null);
// Limite de upload — protege quota do localStorage (~5MB) e evita data URL
// gigante atravessando a UI. JPG/PNG 1920×1080 cabe folgado nesse teto.
const MAX_BG_BYTES = 2 * 1024 * 1024; // 2 MB
function pickFile() {
fileInput.value?.click();
}
function onFileChange(e) {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.add({ severity: 'warn', summary: 'Formato inválido', detail: 'Selecione um arquivo de imagem (JPG, PNG, WEBP).', life: 4000 });
e.target.value = '';
return;
}
if (file.size > MAX_BG_BYTES) {
toast.add({
severity: 'warn',
summary: 'Imagem muito grande',
detail: 'Máximo 2 MB. Reduza a resolução ou compressão e tente novamente.',
life: 4500
});
e.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = (ev) => (bgUrl.value = ev.target.result);
reader.readAsDataURL(file);
}
function clearBg() {
bgUrl.value = '';
}
// Gradiente default — sempre renderizado no .win11-root (atrás de tudo).
// Quando o user faz upload, .win11-photo aparece por cima com opacidade
// controlada pelo slider — permite blend natural com o gradiente abaixo.
// Cores vêm de CSS vars que flipam com dark/light AND seguem o preset
// (ver style global no fim do arquivo: --bloom-c1/c2/base-1/base-2).
const defaultBgStyle = {
backgroundImage:
'radial-gradient(circle at 70% 30%, var(--bloom-c1) 0%, transparent 55%), radial-gradient(circle at 25% 75%, var(--bloom-c2) 0%, transparent 50%), linear-gradient(135deg, var(--bloom-base-1) 0%, var(--bloom-base-2) 50%, var(--bloom-base-1) 100%)',
backgroundSize: 'cover'
};
const photoStyle = computed(() => ({
backgroundImage: bgUrl.value ? `url(${bgUrl.value})` : 'none',
opacity: bgImageOpacity.value
}));
// ───────────────────────────────────────────────────────────────
// Tema (dark/light + cor primária) — usa a infra existente do app
// ───────────────────────────────────────────────────────────────
// `toggleDarkMode` flipa a classe .app-dark + layoutConfig.darkTheme.
// `applyThemeEngine` re-aplica o preset com nova primary/surface.
// `userSettings.queuePatch` persiste no DB (debounced upsert em
// user_settings — colunas theme_mode/primary_color, não toca em melissa_prefs).
const { layoutConfig, toggleDarkMode, isDarkTheme } = useLayout();
const userSettings = useUserSettingsPersistence();
onMounted(() => userSettings.init());
// Paleta enxuta — espelha primaryColors da ProfilePage. 'noir' usa
// currentColor (preto/branco conforme tema) — visualmente vira a primary
// "neutra" pro user que quer monocromático.
const PRIMARY_COLORS = [
{ name: 'noir', swatch: 'currentColor' },
{ name: 'emerald', swatch: '#10b981' },
{ name: 'green', swatch: '#22c55e' },
{ name: 'lime', swatch: '#84cc16' },
{ name: 'orange', swatch: '#f97316' },
{ name: 'amber', swatch: '#f59e0b' },
{ name: 'yellow', swatch: '#eab308' },
{ name: 'teal', swatch: '#14b8a6' },
{ name: 'cyan', swatch: '#06b6d4' },
{ name: 'sky', swatch: '#0ea5e9' },
{ name: 'blue', swatch: '#3b82f6' },
{ name: 'indigo', swatch: '#6366f1' },
{ name: 'violet', swatch: '#8b5cf6' },
{ name: 'purple', swatch: '#a855f7' },
{ name: 'fuchsia', swatch: '#d946ef' },
{ name: 'pink', swatch: '#ec4899' },
{ name: 'rose', swatch: '#f43f5e' }
];
function setDark(shouldBeDark) {
if (isDarkTheme.value === shouldBeDark) return;
toggleDarkMode();
userSettings.queuePatch({ theme_mode: shouldBeDark ? 'dark' : 'light' });
}
function setPrimary(name) {
if (!name || layoutConfig.primary === name) return;
layoutConfig.primary = name;
applyThemeEngine(layoutConfig);
userSettings.queuePatch({ primary_color: name });
}
// ───────────────────────────────────────────────────────────────
// Settings popover (canto superior direito)
// ───────────────────────────────────────────────────────────────
const settingsOpen = ref(false);
// ───────────────────────────────────────────────────────────────
// Mock — Timeline horizontal (8h às 20h)
// ───────────────────────────────────────────────────────────────
const HORA_INICIO = 8;
const HORA_FIM = 20;
const hoursRange = computed(() => {
const arr = [];
for (let h = HORA_INICIO; h <= HORA_FIM; h++) arr.push(h);
return arr;
});
// timelineEvents removido — agora usa `eventosHojeReais` (Supabase)
// Pra debug: ver useMelissaEventos.js em src/layout/melissa/composables/
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')}`;
}
// Contagens por tipo + frase resumo do dia
const contagensDia = computed(() => {
const c = { sessao: 0, supervisao: 0, reuniao: 0 };
for (const ev of eventosHojeReais.value) c[ev.tipo] = (c[ev.tipo] || 0) + 1;
return c;
});
function pluralizar(n, singular, plural) {
return `${n} ${n === 1 ? singular : plural}`;
}
// Partes estruturadas pro template renderizar cada contagem como link clicável
const resumoPartes = computed(() => {
const c = contagensDia.value;
const partes = [];
if (c.sessao > 0) partes.push({ tipo: 'sessao', text: pluralizar(c.sessao, 'atendimento', 'atendimentos') });
if (c.supervisao > 0) partes.push({ tipo: 'supervisao', text: pluralizar(c.supervisao, 'supervisão', 'supervisões') });
if (c.reuniao > 0) partes.push({ tipo: 'reuniao', text: pluralizar(c.reuniao, 'reunião', 'reuniões') });
return partes;
});
// Evento selecionado (dialog de detalhes)
const eventoSelecionado = ref(null);
const eventoBusy = ref(false); // bloqueia botões enquanto UPDATE roda
const melissaAgendaRef = ref(null); // pra chamar openProntuario + setView
const toast = useToast();
const conversationDrawerStore = useConversationDrawerStore();
// ───────────────────────────────────────────────────────────────
// Agenda completa (CRUD + recorrência via AgendaEventDialog)
// ───────────────────────────────────────────────────────────────
// Composable injetado em MelissaAgenda via provide/inject — orquestra
// useAgendaEvents + useRecurrence + useDeterminedCommitments + useFeriados
// e expõe dialog state + handlers (save/delete/série/persistMoveOrResize).
// MelissaAgenda lê M.eventos pra alimentar o FullCalendar e mutará
// M.viewStart/M.viewEnd via FC.datesSet.
const M = useMelissaAgenda();
provide(MELISSA_AGENDA_KEY, M);
// Destrutura refs/computeds pro template auto-unwrappar (refs em objetos
// aninhados NÃO unwrappam no template — só os top-level do setup).
const {
dialogOpen: agendaDialogOpen,
dialogEventRow: agendaDialogEventRow,
dialogStartISO: agendaDialogStartISO,
dialogEndISO: agendaDialogEndISO,
ownerId: agendaOwnerId,
clinicTenantId: agendaClinicTenantId,
commitmentOptions: agendaCommitmentOptions,
workRules: agendaWorkRules,
settings: agendaSettings,
allEventsForDialog: agendaAllEvents,
feriados: agendaFeriados,
bloqueioDialogOpen: agendaBloqueioOpen,
bloqueioMode: agendaBloqueioMode
} = M;
function abrirEvento(ev) {
eventoSelecionado.value = ev;
}
function fecharEvento() {
eventoSelecionado.value = null;
eventoBusy.value = false;
}
// ── Actions do MelissaEventoPanel ──────────────────────────────
// updateStatus: muda status no DB e refetcha agenda. Pattern espelha
// AgendaTerapeutaPage (sem optimistic update por simplicidade no MVP).
async function updateEventoStatus(novoStatus, msgSucesso) {
const ev = eventoSelecionado.value;
if (!ev?.id || eventoBusy.value) return;
eventoBusy.value = true;
try {
const { error } = await supabase
.from('agenda_eventos')
.update({ status: novoStatus })
.eq('id', ev.id);
if (error) throw error;
toast.add({ severity: 'success', summary: msgSucesso, life: 2200 });
// Refetch via composable (eventos reais + ocorrências virtuais)
M.refetch();
fecharEvento();
} catch (e) {
const msg = e?.message || 'Erro ao atualizar evento';
toast.add({ severity: 'error', summary: 'Falha ao atualizar', detail: msg, life: 4000 });
eventoBusy.value = false;
}
}
function onConcluir() { updateEventoStatus('realizado', 'Sessão marcada como realizada'); }
function onFaltou() { updateEventoStatus('faltou', 'Marcada como falta'); }
function onCancelar() { updateEventoStatus('cancelado', 'Evento cancelado'); }
function onWhatsapp() {
const ev = eventoSelecionado.value;
if (!ev?.patient_id) {
toast.add({ severity: 'warn', summary: 'Paciente sem id', life: 2200 });
return;
}
conversationDrawerStore.openForPatient(String(ev.patient_id));
fecharEvento();
}
function onAbrirProntuario() {
const ev = eventoSelecionado.value;
if (!ev?.patient_id) {
toast.add({ severity: 'warn', summary: 'Paciente não vinculado', life: 2200 });
return;
}
const p = pacientesReais.value.find((x) => String(x.id) === String(ev.patient_id));
if (!p) {
toast.add({ severity: 'warn', summary: 'Paciente não encontrado na lista', life: 2200 });
return;
}
melissaAgendaRef.value?.openProntuario?.(p);
fecharEvento();
}
function onHistoricoSessoes() {
// MVP: vai pra view 'lista' do FC (mesmo pattern do dock contextual).
// Filtro real por paciente fica pra fase futura.
melissaAgendaRef.value?.setView?.('lista');
fecharEvento();
}
function onEditEvento() {
// Abre AgendaEventDialog completo via composable. `_raw` carrega o row
// bruto que o dialog precisa (campos de recorrência, financeiro, etc.).
const ev = eventoSelecionado.value;
if (!ev?._raw) {
toast.add({ severity: 'warn', summary: 'Não foi possível abrir o editor', life: 2500 });
return;
}
M.onEditEvento(ev._raw);
fecharEvento();
}
function onRemarcar() {
// MVP: muda status pra 'remarcar'. Reagendar de fato (mover horário)
// sai daqui pra dentro do AgendaEventDialog, via "Editar".
updateEventoStatus('remarcar', 'Marcada pra remarcar');
}
// Filtro da timeline por tipo (clicado a partir do resumo)
const filtroTipo = ref(null);
function toggleFiltro(tipo) {
filtroTipo.value = filtroTipo.value === tipo ? null : tipo;
}
function limparFiltro() {
filtroTipo.value = null;
}
const eventosVisiveis = computed(() => {
if (!filtroTipo.value) return eventosHojeReais.value;
return eventosHojeReais.value.filter((ev) => ev.tipo === filtroTipo.value);
});
const filtroLabel = computed(() => {
if (!filtroTipo.value) return '';
const map = { sessao: 'atendimentos', supervisao: 'supervisões', reuniao: 'reuniões' };
return map[filtroTipo.value] || '';
});
function eventStyle(ev) {
const total = HORA_FIM - HORA_INICIO;
const left = ((ev.startH - HORA_INICIO) / total) * 100;
const width = ((ev.endH - ev.startH) / total) * 100;
return {
left: `${left}%`,
width: `${width}%`,
backgroundColor: ev.color
};
}
const nowCursorLeft = computed(() => {
const d = now.value;
const h = d.getHours() + d.getMinutes() / 60;
if (h < HORA_INICIO || h > HORA_FIM) return '-100%';
const total = HORA_FIM - HORA_INICIO;
return `${((h - HORA_INICIO) / total) * 100}%`;
});
// Visualização vertical (< lg) — tipo calendário "dia"
const VT_HOUR_PX = 48; // altura de cada slot de hora em px
function eventStyleVertical(ev) {
return {
top: `${(ev.startH - HORA_INICIO) * VT_HOUR_PX}px`,
height: `${(ev.endH - ev.startH) * VT_HOUR_PX}px`,
backgroundColor: ev.color
};
}
const nowCursorTop = computed(() => {
const d = now.value;
const h = d.getHours() + d.getMinutes() / 60;
if (h < HORA_INICIO || h > HORA_FIM) return '-100%';
return `${(h - HORA_INICIO) * VT_HOUR_PX}px`;
});
// ───────────────────────────────────────────────────────────────
// Mock — 4 cards
// ───────────────────────────────────────────────────────────────
const proximoPaciente = {
nome: 'Marina Silva',
iniciais: 'MS',
emMin: 23,
horario: '09:00'
};
const whatsappPendente = {
count: 3,
ultimaMsg: '"Doutor, posso remarcar pra quinta?"',
ultimoNome: 'Pedro Costa'
};
const recebiveis = {
previsto: 1450,
recebido: 800,
sessoes: 6,
sessoesPagas: 3
};
const recebidoPct = computed(() => Math.round((recebiveis.recebido / recebiveis.previsto) * 100));
const copilotInsight = {
titulo: 'Paciente sem retorno',
texto: 'Carlos M. não agenda há 32 dias — última sessão sugeria continuidade.',
acao: 'Enviar mensagem'
};
function brl(v) {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
// ───────────────────────────────────────────────────────────────
// Cronômetro de sessão (componente externo)
// ───────────────────────────────────────────────────────────────
// TODO: vir das configurações do tenant (página /configuracoes já existe)
const CONFIG_DURACAO_MIN = 50;
// pacientesMock removido — agora usa `pacientesReais` (composable Supabase).
// Mantido como fallback comentado pra debug rápido em dev se precisar:
// const pacientesMock = [{ id: 'p1', nome: 'Marina Silva' }, ...];
const cronoRef = ref(null);
const cronoVisible = ref(false); // sincronizado via @visible-change
const toqueTermino = ref('sino'); // TODO: persistir nas configs do tenant
function abrirCronometro() {
cronoRef.value?.abrir();
}
function fecharCronometro() {
cronoRef.value?.fechar();
}
function testarToque() {
playToque(toqueTermino.value);
}
// ───────────────────────────────────────────────────────────────
// Persistência de prefs UI
// ───────────────────────────────────────────────────────────────
// Camadas:
// 1. localStorage — cache rápido pra evitar flash no boot e hold do bgUrl
// (data URL pesada não vai pro DB)
// 2. user_settings.melissa_prefs (jsonb) — fonte de verdade pras prefs
// pequenas (toque, opacidades, formato hora, cards). Sobrevive a
// troca de navegador/dispositivo.
//
// Fluxo: onMounted → load localStorage (paint imediato) → load DB (sobrescreve
// se houver). Watch das refs → save localStorage (sync) + save DB (debounced).
// Sanitiza um payload de prefs (DB ou localStorage — ambos são input externo)
function applyPrefsPayload(prefs) {
if (!prefs || typeof prefs !== 'object') return;
if (typeof prefs.toqueTermino === 'string' && TOQUE_IDS.has(prefs.toqueTermino)) {
toqueTermino.value = prefs.toqueTermino;
}
const op = Number(prefs.overlayOpacity);
if (Number.isFinite(op) && op >= 0 && op <= 0.8) {
overlayOpacity.value = op;
}
const bo = Number(prefs.bgImageOpacity);
if (Number.isFinite(bo) && bo >= 0.01 && bo <= 1) {
bgImageOpacity.value = bo;
}
if (typeof prefs.use24h === 'boolean') {
use24h.value = prefs.use24h;
}
if (Array.isArray(prefs.cardsAtivos)) {
const valid = prefs.cardsAtivos.filter((id) => typeof id === 'string' && CARD_IDS.has(id));
if (valid.length > 0) cardsAtivos.value = valid;
}
if (prefs.cardsLayout === 'linha-unica' || prefs.cardsLayout === 'duas-linhas') {
cardsLayout.value = prefs.cardsLayout;
}
}
function currentPrefsSnapshot() {
return {
toqueTermino: toqueTermino.value,
overlayOpacity: overlayOpacity.value,
bgImageOpacity: bgImageOpacity.value,
use24h: use24h.value,
cardsAtivos: cardsAtivos.value,
cardsLayout: cardsLayout.value
};
}
function saveLayoutPrefs() {
// localStorage inclui bgUrl (data URL); DB não — bgUrl fica local
const prefs = { ...currentPrefsSnapshot(), bgUrl: bgUrl.value || '' };
try {
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(prefs));
} catch {
// Quota provavelmente estourou por causa do bgUrl (data URL grande).
// Tenta sem ele — as outras prefs são minúsculas.
try {
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ ...prefs, bgUrl: '' }));
} catch {}
}
}
function loadLocalPrefs() {
let raw;
try { raw = localStorage.getItem(LAYOUT_STORAGE_KEY); } catch { return; }
if (!raw) return;
let prefs;
try { prefs = JSON.parse(raw); } catch { return; }
if (!prefs || typeof prefs !== 'object') return;
applyPrefsPayload(prefs);
// bgUrl: aceita só data URL de imagem (evita injeção de URL externa via storage)
if (typeof prefs.bgUrl === 'string' && prefs.bgUrl.startsWith('data:image/')) {
bgUrl.value = prefs.bgUrl;
}
}
// ── DB sync ────────────────────────────────────────────────────
let dbSaveTimer = null;
let dbReady = false; // só salva no DB depois do load inicial (evita sobrescrever c/ defaults)
async function loadDbPrefs() {
try {
const { data: u } = await supabase.auth.getUser();
const uid = u?.user?.id;
if (!uid) return;
const { data, error } = await supabase
.from('user_settings')
.select('melissa_prefs')
.eq('user_id', uid)
.maybeSingle();
if (error) return;
if (data?.melissa_prefs && typeof data.melissa_prefs === 'object') {
applyPrefsPayload(data.melissa_prefs);
}
} catch {
// silencioso — falha de rede/auth não pode quebrar a UI
} finally {
dbReady = true;
}
}
async function saveDbPrefs() {
if (!dbReady) return;
try {
const { data: u } = await supabase.auth.getUser();
const uid = u?.user?.id;
if (!uid) return;
await supabase
.from('user_settings')
.upsert(
{ user_id: uid, melissa_prefs: currentPrefsSnapshot(), updated_at: new Date().toISOString() },
{ onConflict: 'user_id' }
);
} catch {
// silencioso — pref save não-crítico; localStorage segura o estado até voltar
}
}
function queueDbSave() {
if (dbSaveTimer) clearTimeout(dbSaveTimer);
dbSaveTimer = setTimeout(saveDbPrefs, 600);
}
// Salva em qualquer mudança das prefs (deep no array de cardsAtivos pra pegar splice/push)
watch(
[toqueTermino, overlayOpacity, bgImageOpacity, use24h, bgUrl, cardsAtivos, cardsLayout],
() => {
saveLayoutPrefs();
queueDbSave();
},
{ deep: true }
);
// Carrega antes do paint (use onMounted; o flash inicial é aceitável)
onMounted(async () => {
loadLocalPrefs(); // sync: paint imediato com valores cached
await loadDbPrefs(); // async: sobrescreve com valores autoritativos do DB
});
// ───────────────────────────────────────────────────────────────
// Workspace overlay
// ───────────────────────────────────────────────────────────────
const workspaceOpen = ref(false);
function openWorkspace() {
workspaceOpen.value = true;
}
function closeWorkspace() {
workspaceOpen.value = false;
}
function toggleWorkspace() {
workspaceOpen.value ? closeWorkspace() : openWorkspace();
}
// Resumo recebe blur quando qualquer overlay tá aberto (cronômetro minimizado NÃO conta)
const summaryDimmed = computed(
() => workspaceOpen.value
|| cronoVisible.value
|| !!eventoSelecionado.value
|| !!secaoAberta.value
|| centralOpen.value
);
function onKeydown(e) {
// Ctrl+\ ou ⌘+\ → abre/fecha workspace (psi-btn)
if ((e.ctrlKey || e.metaKey) && e.key === '\\') {
e.preventDefault();
toggleWorkspace();
return;
}
if (e.key !== 'Escape') return;
if (centralOpen.value) centralOpen.value = false;
else if (secaoAberta.value) fecharSecao();
else if (eventoSelecionado.value) fecharEvento();
else if (cronoVisible.value) fecharCronometro();
else if (workspaceOpen.value) closeWorkspace();
else if (settingsOpen.value) settingsOpen.value = false;
}
// modulos[] removido — MelissaMenu agora tem catálogo próprio (CATEGORIAS)
</script>
<template>
<div class="win11-root" :class="{ 'win11-has-photo': !!bgUrl }" :style="defaultBgStyle">
<!-- Camada da foto custom (acima do gradiente, abaixo do dim).
Opacidade controlada pelo slider permite blend com o gradiente
default que vive no .win11-root. -->
<div v-if="bgUrl" class="win11-photo" :style="photoStyle" />
<!-- Overlay escurecedor (controlado pelo slider) -->
<div class="win11-dim" :style="{ backgroundColor: `rgba(var(--m-dim-rgb), ${overlayOpacity})` }" />
<!-- -->
<!-- PLANO DE TRÁS Resumo (recebe blur quando workspace abre) -->
<!-- -->
<div class="win11-summary" :class="{ 'is-behind': summaryDimmed }">
<!-- Botão settings (canto sup. direito) -->
<div class="absolute top-5 right-5 z-30">
<button
class="glass-btn w-10 h-10 grid place-items-center"
title="Personalizar"
@click="settingsOpen = !settingsOpen"
>
<i class="pi pi-cog text-white/90 text-base" />
</button>
<Transition name="settings-pop">
<div
v-if="settingsOpen"
class="glass-panel absolute top-12 right-0 w-72 p-4 text-white/95"
>
<div class="text-xs uppercase tracking-widest text-white/60 mb-3">Personalização</div>
<button
class="w-full text-left px-3 py-2 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 mb-1.5 flex items-center gap-2 text-sm"
@click="pickFile"
>
<i class="pi pi-image" />
Trocar imagem de fundo
</button>
<p class="text-[0.68rem] leading-snug text-white/50 px-1 mb-2.5">
Recomendado: 1920×1080 (Full HD), JPG ou PNG. Tamanho máximo: 2&nbsp;MB.
</p>
<button
v-if="bgUrl"
class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/10 mb-3 flex items-center gap-2 text-sm text-white/70"
@click="clearBg"
>
<i class="pi pi-refresh" />
Voltar ao padrão
</button>
<input ref="fileInput" type="file" accept="image/*" hidden @change="onFileChange" />
<div v-if="bgUrl" class="mb-3">
<label class="text-xs text-white/60 mb-1.5 block">
Transparência da imagem: {{ Math.round(bgImageOpacity * 100) }}%
</label>
<input
v-model.number="bgImageOpacity"
type="range"
min="0.01"
max="1"
step="0.01"
class="settings-range w-full"
/>
</div>
<div class="mb-3">
<label class="text-xs text-white/60 mb-1.5 block">
Opacidade do fundo: {{ Math.round(overlayOpacity * 100) }}%
</label>
<input
v-model.number="overlayOpacity"
type="range"
min="0"
max="0.8"
step="0.05"
class="settings-range w-full"
/>
</div>
<div class="mb-3">
<label class="text-xs text-white/60 mb-1.5 block">Toque de término</label>
<div class="flex gap-1.5">
<select v-model="toqueTermino" class="settings-select flex-1">
<option v-for="t in TOQUES" :key="t.id" :value="t.id">
{{ t.label }}
</option>
</select>
<button
class="settings-test-btn"
title="Testar"
:disabled="toqueTermino === 'nenhum'"
@click="testarToque"
>
<i class="pi pi-play text-[0.65rem]" />
</button>
</div>
</div>
<div class="flex items-center justify-between text-sm mb-3">
<span class="text-white/80">
Formato 24h
<span class="text-white/45 text-[0.7rem]">(relógio)</span>
</span>
<button
class="w-10 h-6 rounded-full transition-colors relative settings-toggle"
:class="use24h ? 'is-on' : 'bg-white/20'"
@click="use24h = !use24h"
>
<span
class="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all"
:style="{ left: use24h ? '1.125rem' : '0.125rem' }"
/>
</button>
</div>
<div class="flex items-center justify-between text-sm mb-3">
<span class="text-white/80">Modo escuro</span>
<button
class="w-10 h-6 rounded-full transition-colors relative settings-toggle"
:class="isDarkTheme ? 'is-on' : 'bg-white/20'"
@click="setDark(!isDarkTheme)"
:title="isDarkTheme ? 'Mudar pra claro' : 'Mudar pra escuro'"
>
<span
class="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all"
:style="{ left: isDarkTheme ? '1.125rem' : '0.125rem' }"
/>
</button>
</div>
<div>
<label class="text-xs text-white/60 mb-1.5 block">Cor primária</label>
<div class="grid grid-cols-9 gap-1.5">
<button
v-for="pc in PRIMARY_COLORS"
:key="pc.name"
class="settings-swatch"
:class="{ 'is-active': layoutConfig.primary === pc.name }"
:style="{ backgroundColor: pc.swatch === 'currentColor' ? 'var(--m-text)' : pc.swatch }"
:title="pc.name"
@click="setPrimary(pc.name)"
/>
</div>
</div>
</div>
</Transition>
</div>
<!-- Conteúdo central -->
<div class="win11-summary__inner">
<!-- Bloco hero: relógio + data + saudação -->
<header class="text-center text-white drop-shadow-lg select-none">
<div class="inline-flex items-center gap-6">
<div class="clock-display">{{ horaFormatada }}</div>
<button
class="crono-icon-btn"
title="Cronômetro de sessão"
@click="abrirCronometro"
>
<i class="pi pi-stopwatch text-xl text-white/85" />
</button>
</div>
<div class="text-lg md:text-xl font-light tracking-wide text-white/90 mt-1 capitalize">
{{ dataExtenso }}
</div>
<div class="text-2xl md:text-3xl font-light mt-6 text-white/95 tracking-tight">
{{ saudacao }}, <span class="font-normal">Dr. Leonardo</span>.
</div>
<div class="text-base md:text-lg font-light text-white/70 mt-2 tracking-wide">
<template v-if="resumoPartes.length === 0">
Sua agenda está livre hoje.
</template>
<template v-else>
Hoje
<template v-for="(p, i) in resumoPartes" :key="p.tipo">
<button
type="button"
class="resumo-link"
:class="{ 'is-active': filtroTipo === p.tipo }"
@click="toggleFiltro(p.tipo)"
>{{ p.text }}</button><span v-if="i < resumoPartes.length - 2">,&nbsp;</span><span v-else-if="i === resumoPartes.length - 2">&nbsp;e&nbsp;</span>
</template>.
</template>
</div>
</header>
<!-- Busca rápida (entre o "Hoje " e a timeline) -->
<MelissaBusca
class="mt-8"
:pacientes="pacientesReais"
:eventos="eventosHojeReais"
@acao="abrirSecao"
@paciente="() => abrirSecao('pacientes')"
@evento="abrirEvento"
/>
<!-- Timeline horizontal -->
<section class="glass-panel mt-8 px-5 py-4 max-w-5xl w-full mx-auto">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2 text-white/90 text-sm font-medium">
<i class="pi pi-clock text-xs" />
Linha do tempo Hoje
<button
v-if="filtroTipo"
type="button"
class="filtro-chip"
@click="limparFiltro"
:title="`Mostrando apenas ${filtroLabel}. Clique pra mostrar tudo.`"
>
{{ filtroLabel }}
<i class="pi pi-times text-[0.6rem]" />
</button>
</div>
<div class="text-xs text-white/70 flex items-center gap-1.5">
<span class="pulse-dot w-2.5 h-0.5 rounded-full bg-red-500" />
Agora
</div>
</div>
<!-- Horizontal (lg+) -->
<div class="relative hidden lg:block">
<div class="flex justify-between mb-1">
<div v-for="h in hoursRange" :key="h" class="flex-1 text-left">
<span class="text-[0.65rem] text-white/55 font-medium">{{ h }}h</span>
</div>
</div>
<div class="relative h-9 bg-white/5 rounded-md overflow-visible border border-white/10">
<div
v-for="ev in eventosVisiveis"
:key="ev.id"
class="absolute h-[30px] rounded flex items-center px-2 overflow-hidden cursor-pointer min-w-[32px] hover:brightness-110 transition-[filter,opacity] duration-200 z-10"
:style="eventStyle(ev)"
:title="ev.label"
@click="abrirEvento(ev)"
>
<span class="text-[0.8rem] font-semibold text-white truncate">{{ ev.label }}</span>
</div>
<div
class="absolute top-0 h-full flex flex-col items-center pointer-events-none z-20"
:style="{ left: nowCursorLeft }"
>
<div class="w-0.5 h-full bg-red-500 opacity-90" />
<div class="absolute -top-0.5 w-[7px] h-[7px] rounded-full bg-red-500" />
</div>
</div>
</div>
<!-- Vertical (< lg) tipo calendário "dia" -->
<div class="vt lg:hidden">
<div
v-for="h in hoursRange"
:key="h"
class="vt-row"
:style="{ top: `${(h - HORA_INICIO) * VT_HOUR_PX}px` }"
>
<span class="vt-hour">{{ h }}h</span>
<div class="vt-line" />
</div>
<div
v-for="ev in eventosVisiveis"
:key="ev.id"
class="vt-event hover:brightness-110 transition-[filter,opacity] duration-200"
:style="eventStyleVertical(ev)"
:title="ev.label"
@click="abrirEvento(ev)"
>
<div class="vt-event-time">
{{ fmtHora(ev.startH) }} {{ fmtHora(ev.endH) }}
</div>
<div class="vt-event-label">{{ ev.label }}</div>
</div>
<div class="vt-now" :style="{ top: nowCursorTop }">
<div class="vt-now-dot" />
<div class="vt-now-line" />
</div>
</div>
</section>
<!-- Cards (catálogo + ativos + layout switchável) -->
<section
class="cards-shell mt-5 w-full mx-auto"
:class="{ 'is-wrap': cardsLayout === 'duas-linhas' }"
>
<div
class="cards-row"
:class="cardsLayout === 'linha-unica' ? 'cards-row--linha' : 'cards-row--wrap'"
>
<template v-for="cardId in cardsAtivos" :key="cardId">
<!-- Próximo paciente -->
<MelissaCard
v-if="cardId === 'proximo-paciente'"
icon="pi pi-user"
icon-color="text-emerald-300"
title="Próximo paciente"
action-title="Abrir Pacientes"
@open="abrirSecao('pacientes')"
>
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-full bg-emerald-500/30 border border-emerald-300/40 grid place-items-center text-white font-semibold">
{{ proximoPaciente.iniciais }}
</div>
<div class="min-w-0">
<div class="text-white font-medium truncate">{{ proximoPaciente.nome }}</div>
<div class="text-white/60 text-xs">
{{ proximoPaciente.horario }} · em {{ proximoPaciente.emMin }} min
</div>
</div>
</div>
</MelissaCard>
<!-- WhatsApp -->
<MelissaCard
v-else-if="cardId === 'whatsapp'"
icon="pi pi-whatsapp"
icon-color="text-green-300"
title="WhatsApp"
:badge="whatsappPendente.count"
action-title="Abrir WhatsApp"
@open="abrirSecao('conversas')"
>
<div class="text-white/85 text-xs italic line-clamp-2 leading-relaxed">
{{ whatsappPendente.ultimaMsg }}
</div>
<div class="text-white/50 text-[0.7rem] mt-1"> {{ whatsappPendente.ultimoNome }}</div>
</MelissaCard>
<!-- Recebíveis -->
<MelissaCard
v-else-if="cardId === 'recebiveis'"
icon="pi pi-wallet"
icon-color="text-amber-300"
title="Recebíveis hoje"
action-title="Abrir Financeiro"
@open="abrirSecao('financeiro')"
>
<div class="flex items-baseline gap-1.5">
<span class="text-white text-xl font-medium">{{ brl(recebiveis.recebido) }}</span>
<span class="text-white/50 text-xs">/ {{ brl(recebiveis.previsto) }}</span>
</div>
<div class="h-1.5 rounded-full bg-white/10 mt-2 overflow-hidden">
<div
class="h-full bg-gradient-to-r from-amber-400 to-amber-300 transition-all"
:style="{ width: `${recebidoPct}%` }"
/>
</div>
<div class="text-white/55 text-[0.7rem] mt-1.5">
{{ recebiveis.sessoesPagas }}/{{ recebiveis.sessoes }} sessões pagas
</div>
</MelissaCard>
<!-- Copilot -->
<MelissaCard
v-else-if="cardId === 'copilot'"
icon="pi pi-sparkles"
icon-color="text-purple-300"
title="Sugestão Copilot"
action-title="Abrir Copilot"
@open="abrirSecao('copilot')"
>
<div class="text-white/85 text-xs font-medium">{{ copilotInsight.titulo }}</div>
<div class="text-white/65 text-[0.7rem] mt-1 leading-relaxed">
{{ copilotInsight.texto }}
</div>
</MelissaCard>
<!-- Card "em breve" placeholder pra ids do catálogo ainda não implementados -->
<MelissaCard
v-else
:icon="getCatalogItem(cardId)?.icon || 'pi pi-clock'"
icon-color="text-white/40"
:title="getCatalogItem(cardId)?.label || cardId"
action-title="Em breve"
@open="abrirSecao('configuracoes')"
>
<div class="text-white/40 text-xs italic">Em breve</div>
</MelissaCard>
</template>
<!-- Em wrap: "+" segue o fluxo, ao final dos cards, com altura
espelhada (flex align-items: stretch faz isso natural) -->
<MelissaCard
v-if="cardsLayout === 'duas-linhas'"
variant="add"
action-title="Personalizar cards"
@add="centralOpen = true"
/>
</div>
<!-- Em linha única: "+" fora do scroll, sempre visível à direita -->
<MelissaCard
v-if="cardsLayout === 'linha-unica'"
variant="add"
action-title="Personalizar cards"
@add="centralOpen = true"
/>
</section>
</div>
</div>
<!-- /win11-summary fecha aqui -->
<!-- Botão único ψ FORA da summary (não recebe blur/pointer-none
quando overlays estão abertos; permanece sempre clicável).
Continua position:absolute (não é filho do dock), mas vive na
mesma faixa visual da .melissa-dock (ambos no bottom). -->
<button
class="psi-btn"
:title="workspaceOpen ? 'Fechar workspace (Ctrl + \\)' : 'Abrir workspace (Ctrl + \\)'"
@click="toggleWorkspace"
>
<span class="psi-glyph">ψ</span>
<span class="psi-kbd" aria-hidden="true">Ctrl \</span>
</button>
<!-- -->
<!-- DOCK (taskbar Win11-style sem chrome) receptáculo pra -->
<!-- elementos minimizados (cronômetro, futuros). Transparent,-->
<!-- os items são clicáveis. ψ vive ao lado (absolute). -->
<!-- -->
<div class="melissa-dock" />
<!-- -->
<!-- MENU FLOAT (Win11-style Start) abre via ψ ou Ctrl+\ -->
<!-- -->
<Transition name="menu-rise">
<MelissaMenu
v-if="workspaceOpen"
:secao-ativa="secaoAberta"
@select="abrirSecao"
@close="closeWorkspace"
/>
</Transition>
<!-- -->
<!-- DIALOG Evento da timeline (sessão / supervisão / reunião) -->
<!-- -->
<Transition name="lift">
<MelissaEventoPanel
v-if="eventoSelecionado"
:evento="eventoSelecionado"
:busy="eventoBusy"
@close="fecharEvento"
@concluir="onConcluir"
@faltou="onFaltou"
@cancelar="onCancelar"
@remarcar="onRemarcar"
@edit="onEditEvento"
@abrir-prontuario="onAbrirProntuario"
@whatsapp="onWhatsapp"
@historico="onHistoricoSessoes"
/>
</Transition>
<!-- -->
<!-- CENTRAL DE CARDS toggle de cards + modo de visualização -->
<!-- -->
<Transition name="lift">
<div
v-if="centralOpen"
class="central-layer"
@click.self="centralOpen = false"
>
<div class="central-panel">
<header class="flex items-start justify-between mb-5">
<div>
<div class="text-white/55 text-xs uppercase tracking-[0.2em]">Central de cards</div>
<div class="text-white text-xl font-light mt-1">Personalize seu resumo</div>
</div>
<button
class="glass-btn w-9 h-9 grid place-items-center shrink-0"
title="Fechar (Esc)"
@click="centralOpen = false"
>
<i class="pi pi-times text-white/90 text-sm" />
</button>
</header>
<!-- Modo de visualização -->
<div class="mb-5">
<div class="text-[0.65rem] uppercase tracking-widest text-white/50 mb-2">Visualização</div>
<div class="flex flex-col gap-2">
<label class="central-radio">
<input type="radio" v-model="cardsLayout" value="linha-unica" />
<span>Linha única <span class="text-white/40">(scroll horizontal)</span></span>
</label>
<label class="central-radio">
<input type="radio" v-model="cardsLayout" value="duas-linhas" />
<span>Duas linhas <span class="text-white/40">(quebra automática)</span></span>
</label>
</div>
</div>
<!-- Cards disponíveis -->
<div>
<div class="text-[0.65rem] uppercase tracking-widest text-white/50 mb-2">
Cards disponíveis
</div>
<div class="flex flex-col gap-1">
<label
v-for="c in CARDS_CATALOG"
:key="c.id"
class="central-card-row"
:class="{ 'is-em-breve': !c.implementado }"
>
<input
type="checkbox"
:checked="cardsAtivos.includes(c.id)"
@change="toggleCardAtivo(c.id)"
/>
<i :class="c.icon" class="text-white/70 w-4 text-center" />
<span class="flex-1">{{ c.label }}</span>
<span v-if="!c.implementado" class="text-[0.65rem] text-white/40">em breve</span>
</label>
</div>
</div>
</div>
</div>
</Transition>
<!-- -->
<!-- SESSÕES "promovidas" páginas fullscreen dentro de Melissa -->
<!-- (não usam o dialog placeholder, viram páginas próprias) -->
<!-- -->
<Transition name="page-fade">
<MelissaAgenda
v-if="secaoAberta === 'agenda'"
ref="melissaAgendaRef"
:pacientes="pacientesReais"
@select-evento="abrirEvento"
@close="fecharSecao"
@patient-created="refetchPacientes"
/>
</Transition>
<Transition name="page-fade">
<MelissaPacientes
v-if="secaoAberta === 'pacientes'"
@close="fecharSecao"
@patient-created="refetchPacientes"
@goto-agenda="abrirSecao('agenda')"
/>
</Transition>
<!-- -->
<!-- SEÇÃO placeholder dialog pras sessões ainda não promovidas -->
<!-- (WhatsApp, Financeiro, Copilot...) -->
<!-- -->
<Transition name="lift">
<div
v-if="secaoAberta && secaoAberta !== 'agenda' && secaoAberta !== 'pacientes'"
class="secao-layer"
@click.self="fecharSecao"
>
<div class="secao-panel">
<header class="flex items-start justify-between mb-6">
<div class="flex items-center gap-3 min-w-0">
<div class="secao-icon">
<i :class="SECOES[secaoAberta].icon" class="text-xl text-white/85" />
</div>
<div class="min-w-0">
<div class="text-white/55 text-xs uppercase tracking-[0.2em]">Seção</div>
<div class="text-white text-2xl font-light mt-1">
{{ SECOES[secaoAberta].label }}
</div>
</div>
</div>
<button
class="glass-btn w-9 h-9 grid place-items-center shrink-0"
title="Fechar (Esc)"
@click="fecharSecao"
>
<i class="pi pi-times text-white/90 text-sm" />
</button>
</header>
<div class="secao-body">
<i :class="SECOES[secaoAberta].icon" class="secao-body-icon" />
<div class="text-white/75 text-base font-light max-w-md text-center">
{{ SECOES[secaoAberta].descricao }}
</div>
<div class="text-white/35 text-xs mt-3 text-center">
Placeholder aqui virá o módulo de {{ SECOES[secaoAberta].label }}.
</div>
</div>
<footer class="text-white/40 text-[0.7rem] text-center mt-4">
Pressione <kbd class="px-1.5 py-0.5 rounded bg-white/10 border border-white/15">Esc</kbd>
ou clique fora para voltar ao resumo
</footer>
</div>
</div>
</Transition>
<!-- Cronômetro (componente externo, gerencia próprio estado) -->
<MelissaCronometro
ref="cronoRef"
:pacientes="pacientesReais"
:default-paciente-id="null"
:duracao-minutos="CONFIG_DURACAO_MIN"
:toque-termino="toqueTermino"
@visible-change="cronoVisible = $event"
/>
<!-- Drawer de conversas (WhatsApp): mesmo padrão do AppLayout.
Sem ele montado, conversationDrawerStore.openForPatient() ativa o
estado mas não tem componente reativo pra abrir. -->
<ConversationDrawer />
<!-- ConfirmDialog: usado pelos handlers da agenda (drag/resize -->
<!-- pede confirmação). Auto-resolvido via PrimeVueResolver. -->
<!-- -->
<!-- Slot #message override: persistMoveOrResize gera mensagens -->
<!-- com <strong> ao redor de datas/horários. v-html renderiza -->
<!-- HTML; nome do paciente é escapado em useMelissaAgenda -->
<!-- (_esc) pra evitar XSS. -->
<ConfirmDialog>
<template #message="slotProps">
<span class="p-confirm-dialog-message" v-html="slotProps.message.message" />
</template>
</ConfirmDialog>
<!-- -->
<!-- AgendaEventDialog editor completo (CRUD + recorrência -->
<!-- + financeiro). Vive no nível do MelissaLayout pra cobrir -->
<!-- toda a tela e ser independente de qual seção está aberta. -->
<!-- Os handlers (M.onDialogSave/Delete/etc) e o estado vêm do -->
<!-- composable useMelissaAgenda. -->
<!-- -->
<AgendaEventDialog
v-model="agendaDialogOpen"
:eventRow="agendaDialogEventRow"
:initialStartISO="agendaDialogStartISO"
:initialEndISO="agendaDialogEndISO"
:ownerId="agendaOwnerId"
:tenantId="agendaClinicTenantId"
:commitmentOptions="agendaCommitmentOptions"
:workRules="agendaWorkRules"
:blockedDates="[]"
:agendaSettings="agendaSettings"
:allEvents="agendaAllEvents"
:pausasSemanais="agendaSettings?.pausas_semanais || []"
:feriados="agendaFeriados"
newPatientRoute="/therapist/patients/cadastro"
@save="M.onDialogSave"
@delete="M.onDialogDelete"
@updateSeriesEvent="M.onUpdateSeriesEvent"
@editSeriesOccurrence="M.onEditSeriesOccurrence"
/>
<!-- BloqueioDialog bloqueio de horário/período/dia/feriados.
Trigger é o menu na toolbar da MelissaAgenda. Após salvar,
refetcha pra refletir o bloqueio na agenda. -->
<BloqueioDialog
v-model="agendaBloqueioOpen"
:mode="agendaBloqueioMode"
:workRules="agendaWorkRules"
:settings="agendaSettings"
:ownerId="agendaOwnerId"
:tenantId="agendaClinicTenantId"
@saved="M.refetch"
/>
</div>
</template>
<style scoped>
/* ─── Root ─────────────────────────────────────────────────── */
.win11-root {
position: fixed;
inset: 0;
overflow: hidden;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
/* Camada da foto custom — fica entre o gradiente default (.win11-root)
e o dim. Opacidade vem do slider via inline style. */
.win11-photo {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
pointer-events: none;
transition: opacity 200ms ease;
}
.win11-dim {
position: absolute;
inset: 0;
transition: background-color 200ms ease;
}
/* ─── Plano de trás (resumo) ───────────────────────────────── */
.win11-summary {
position: relative;
z-index: 10;
width: 100%;
height: 100%;
transition: filter 320ms ease, transform 320ms ease;
}
.win11-summary.is-behind {
filter: blur(14px) brightness(0.7);
transform: scale(0.98);
pointer-events: none;
}
.win11-summary__inner {
width: 100%;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
align-items: center;
/* `safe center`: centraliza vertical quando o conteúdo cabe;
vira flex-start (sem clipar) quando excede e exige scroll */
justify-content: safe center;
padding: 4rem 2rem 6rem;
/* Scrollbar discreta */
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.win11-summary__inner::-webkit-scrollbar {
width: 6px;
}
.win11-summary__inner::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* ─── Relógio ──────────────────────────────────────────────── */
.clock-display {
font-size: clamp(5rem, 12vw, 9rem);
font-weight: 200;
line-height: 1;
letter-spacing: -0.04em;
font-variant-numeric: tabular-nums;
color: white;
text-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
/* ─── Glass primitives ─────────────────────────────────────── */
.glass-panel {
background: var(--m-bg-soft);
backdrop-filter: blur(24px) saturate(140%);
-webkit-backdrop-filter: blur(24px) saturate(140%);
border: 1px solid var(--m-border);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.glass-btn {
background: var(--m-bg-soft);
backdrop-filter: blur(20px);
border: 1px solid var(--m-border-strong);
border-radius: 9999px;
transition: background-color 160ms ease;
cursor: pointer;
}
.glass-btn:hover {
background: var(--m-bg-soft-hover);
}
/* ─── Shell: cards-row scroll/wrap + card "+" sempre fora ──── */
.cards-shell {
display: flex;
align-items: stretch; /* "+" estica até a altura do row (linha única) */
gap: 1rem;
/* Sem padding aqui: o respiro vai dentro do .cards-row (pra estar
dentro da área que tem overflow controlado). */
}
.cards-shell.is-wrap {
align-items: flex-start; /* em wrap o "+" não estica (cards-row pode ter 2+ linhas) */
}
/* Row interno: ocupa o espaço restante; comporta cards (scroll ou wrap) */
.cards-row {
flex: 1;
min-width: 0; /* essencial pra flex item permitir overflow-x */
display: flex;
gap: 1rem;
}
.cards-row--linha {
flex-wrap: nowrap;
overflow-x: auto;
/* `overflow-y: hidden` explícito suprime o scroll vertical que o
browser adicionaria (overflow-x:auto força overflow-y:auto). O
padding abaixo já deu altura suficiente pra lift e botão "+". */
overflow-y: hidden;
/* `safe center`: centraliza quando os cards cabem; vira start
(sem clipar) quando overflow exigir scroll horizontal */
justify-content: safe center;
/* Padding interno (só nesse modo, pq o overflow-y:hidden exige):
- top: 8px → respiro pro hover-lift
- bottom: 24px → respiro pro botão "+" do card (bottom: -16px)
- lateral: 4px → pra cards das pontas não colarem */
padding: 8px 4px 24px;
/* Scrollbar horizontal discreta */
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.cards-row--linha::-webkit-scrollbar {
height: 6px;
}
.cards-row--linha::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.cards-row--wrap {
flex-wrap: wrap;
justify-content: center;
/* Wrap não precisa do padding/overflow do linha-única.
row-gap maior pra botão "+" da linha de cima não colar nas
cards da linha de baixo. */
row-gap: 2rem;
padding-bottom: 16px;
}
/* Card "+" em linha única: tamanho fixo + margin pra alinhar
visualmente com os cards (que estão dentro do padding-top: 8 da row).
Usa :deep() pra atravessar o scope do MelissaCard.vue. */
.cards-shell:not(.is-wrap) :deep(.mc-card-add) {
height: 100px;
margin-top: 8px;
}
/* Mobile: "+" vira pill mínimo na linha única (economiza horizontal) */
@media (max-width: 640px) {
.cards-shell:not(.is-wrap) :deep(.mc-card-add) {
width: 30px;
border-radius: 9px;
}
}
/* ─── Central de cards (dialog de personalização) ─────────── */
.central-layer {
position: absolute;
inset: 0;
z-index: 56;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.central-panel {
width: min(440px, 100%);
max-height: 80vh;
overflow: auto;
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);
}
.central-radio {
display: flex;
align-items: center;
gap: 10px;
color: var(--m-text);
font-size: 0.85rem;
cursor: pointer;
padding: 6px 8px;
border-radius: 8px;
transition: background-color 140ms ease;
}
.central-radio:hover {
background: var(--m-bg-soft);
}
.central-radio input[type='radio'] {
accent-color: #34d399;
cursor: pointer;
}
.central-card-row {
display: flex;
align-items: center;
gap: 10px;
color: var(--m-text);
font-size: 0.85rem;
cursor: pointer;
padding: 8px 10px;
border-radius: 8px;
transition: background-color 140ms ease;
}
.central-card-row:hover {
background: var(--m-bg-soft);
}
.central-card-row input[type='checkbox'] {
accent-color: #34d399;
cursor: pointer;
}
.central-card-row.is-em-breve {
color: var(--m-text-muted);
}
/* ─── Botão ψ (avatar — futuramente logo da empresa) ─────── */
/* z-index alto: sempre acima de QUALQUER overlay (page, menu, dialogs)
pra continuar acessível mesmo com cronômetro/agenda/secao abertos.
Vive centralizado verticalmente na faixa do .melissa-dock (76px) —
bottom calculado pra centralizar o ψ de 56px na faixa: (76-56)/2 = 10px. */
.psi-btn {
position: absolute;
bottom: 10px;
left: 1.25rem;
z-index: 70;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--m-bg-soft-hover);
backdrop-filter: blur(24px) saturate(160%);
-webkit-backdrop-filter: blur(24px) saturate(160%);
border: 1px solid var(--m-border-strong);
display: grid;
place-items: center;
cursor: pointer;
transition: all 200ms ease;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
/* (.melissa-dock vive no bloco <style> global no fim do arquivo —
tem que ser global porque (a) o nó <div class="melissa-dock"/> é
static-hoisted pelo compiler e perde o data-v scoped, e (b) recebe
filhos via Teleport de outros componentes que precisam herdar pointer-events) */
.psi-btn:hover {
background: var(--m-border-strong);
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
}
.psi-glyph {
font-family: 'Instrument Serif', Georgia, serif;
font-size: 2rem;
line-height: 1;
color: white;
font-style: italic;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
/* ψ tem descender pronunciado (cauda que vai abaixo da baseline);
o `place-items: center` do botão centraliza pela line-box, não pelo
glifo, então empurro 3px pra cima pra equilibrar visualmente. */
transform: translateY(-3px);
}
/* Pill overlay com atalho de teclado, ancorado no canto inferior-direito do ψ */
.psi-kbd {
position: absolute;
bottom: calc(-15% + 8px);
left: 60%;
/* Bg/color usam tokens — em dark vira pílula escura com texto claro,
em light vira pílula clara com texto escuro. --m-kbd-* definidos
no bloco global de tokens no fim do arquivo. */
background: var(--m-kbd-bg);
color: var(--m-kbd-text);
padding: 3px 9px;
border-radius: 9999px;
font-size: 0.6rem;
font-weight: 500;
letter-spacing: 0.05em;
white-space: nowrap;
pointer-events: none;
border: 1px solid var(--m-border-strong);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
/* ─── Settings popover: select + botão testar do toque ────── */
.settings-select {
background: var(--m-bg-soft);
border: 1px solid var(--m-border-strong);
color: var(--m-text);
padding: 7px 10px;
border-radius: 8px;
font-size: 0.8rem;
outline: none;
appearance: none;
-webkit-appearance: none;
cursor: pointer;
min-width: 0;
}
.settings-select:hover {
background: var(--m-bg-soft-hover);
}
.settings-select option {
/* renderizado pelo OS — usa tokens semânticos pra acompanhar dark/light */
background: var(--p-content-background);
color: var(--m-text);
}
.settings-test-btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
background: var(--m-bg-soft);
border: 1px solid var(--m-border-strong);
color: var(--m-text);
border-radius: 8px;
cursor: pointer;
transition: background-color 140ms ease;
}
.settings-test-btn:hover {
background: var(--m-accent);
border-color: var(--m-accent);
}
.settings-test-btn:disabled {
background: var(--m-bg-soft);
border-color: var(--m-border);
color: var(--m-text-faint);
cursor: not-allowed;
}
/* Sliders do painel de personalização — usa accent-color (pinta thumb +
parte preenchida na primary). Não sobrescrevemos ::-webkit-slider-track
pra preservar o alinhamento vertical nativo da bolinha (Chrome entra
em modo "custom" se a track for estilizada e a thumb desce). */
.settings-range {
accent-color: var(--p-primary-color);
width: 100%;
}
/* Toggle "ligado" usa a primary do preset escolhido (não emerald hardcoded
como antes — agora todo o painel reflete a paleta selecionada). */
.settings-toggle.is-on {
background-color: var(--p-primary-color);
}
/* Swatches de cor primária — círculos compactos com ring na ativa. */
.settings-swatch {
width: 22px;
height: 22px;
border-radius: 9999px;
border: 1px solid rgba(255, 255, 255, 0.18);
cursor: pointer;
transition: transform 120ms ease, box-shadow 120ms ease;
padding: 0;
}
.settings-swatch:hover {
transform: scale(1.12);
}
.settings-swatch.is-active {
box-shadow:
0 0 0 2px var(--m-bg-medium),
0 0 0 4px var(--p-primary-color);
}
/* ─── Botão-trigger do cronômetro (irmão flex do relógio) ───── */
.crono-icon-btn {
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--m-bg-soft-hover);
backdrop-filter: blur(20px) saturate(160%);
-webkit-backdrop-filter: blur(20px) saturate(160%);
border: 1px solid var(--m-border-strong);
display: grid;
place-items: center;
cursor: pointer;
transition: all 200ms ease;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.2);
flex-shrink: 0;
}
.crono-icon-btn:hover {
background: var(--m-border-strong);
transform: scale(1.06);
}
/* ─── Seção: overlay grande (Pacientes, WhatsApp, etc.) ─────── */
.secao-layer {
position: absolute;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.secao-panel {
width: min(1100px, 100%);
height: min(80vh, 720px);
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);
display: flex;
flex-direction: column;
}
.secao-icon {
width: 44px;
height: 44px;
border-radius: 12px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border-strong);
display: grid;
place-items: center;
flex-shrink: 0;
}
.secao-body {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: auto;
padding: 1rem;
}
.secao-body-icon {
font-size: 4.5rem;
color: var(--m-text-faint);
margin-bottom: 1.5rem;
}
/* (Antes vivia aqui o CSS do panel inline de evento — extraído pra
MelissaEventoPanel.vue em 2026-04-27 junto com o componente.) */
/* ─── MelissaPage — transição "fade" pra páginas fullscreen ─── */
.page-fade-enter-active,
.page-fade-leave-active {
transition: opacity 220ms ease, transform 260ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
.page-fade-enter-from {
opacity: 0;
transform: scale(0.985);
}
.page-fade-leave-to {
opacity: 0;
transform: scale(0.99);
}
/* ─── MelissaMenu — transição "rise" no .mm-panel (não no layer) ─── */
.menu-rise-enter-active :deep(.mm-panel),
.menu-rise-leave-active :deep(.mm-panel) {
transition: opacity 240ms ease, transform 280ms cubic-bezier(0.2, 0.7, 0.3, 1);
transform-origin: bottom left;
}
.menu-rise-enter-from :deep(.mm-panel) {
opacity: 0;
transform: translateY(16px) scale(0.97);
}
.menu-rise-leave-to :deep(.mm-panel) {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
/* ─── Links clicáveis dentro do resumo do dia ─────────────── */
.resumo-link {
display: inline;
background: none;
border: none;
padding: 0;
margin: 0;
font: inherit;
color: var(--m-text);
cursor: pointer;
border-bottom: 1px dashed var(--m-border-strong);
transition: color 160ms ease, border-color 160ms ease;
}
.resumo-link:hover {
color: white;
border-bottom-color: var(--m-text-muted);
}
.resumo-link.is-active {
color: white;
font-weight: 500;
border-bottom: 1px solid var(--m-text);
}
/* ─── Chip do filtro ativo (header da timeline) ────────────── */
.filtro-chip {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 6px;
padding: 2px 8px 2px 10px;
border-radius: 9999px;
background: var(--m-bg-soft-hover);
border: 1px solid var(--m-border-strong);
color: var(--m-text);
font-size: 0.7rem;
font-weight: 500;
cursor: pointer;
transition: background-color 140ms ease;
}
.filtro-chip:hover {
background: var(--m-border-strong);
}
/* ─── Timeline vertical (< lg) — tipo calendário dia ─────── */
.vt {
position: relative;
height: calc((20 - 8) * 48px + 24px); /* HORA_FIM-HORA_INICIO+respiro */
margin-top: 0.75rem;
}
.vt-row {
position: absolute;
left: 0;
right: 0;
height: 0;
display: flex;
align-items: center;
}
.vt-hour {
width: 36px;
text-align: right;
padding-right: 8px;
color: var(--m-text-muted);
font-size: 0.65rem;
font-weight: 500;
flex-shrink: 0;
/* O texto fica visualmente centrado na linha (transform shift up half) */
transform: translateY(-50%);
background: transparent;
line-height: 1;
}
.vt-line {
flex: 1;
height: 1px;
background: var(--m-bg-soft);
}
.vt-event {
position: absolute;
left: 44px;
right: 4px;
padding: 4px 10px;
border-radius: 4px;
color: white;
cursor: pointer;
overflow: hidden;
z-index: 1;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 24px;
}
.vt-event-time {
font-size: 0.62rem;
opacity: 0.85;
line-height: 1.1;
}
.vt-event-label {
font-size: 0.78rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
}
.vt-now {
position: absolute;
left: 32px;
right: 0;
z-index: 2;
pointer-events: none;
display: flex;
align-items: center;
transform: translateY(-50%); /* centro do cursor sobre a linha do horário */
}
.vt-now-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgb(239, 68, 68);
flex-shrink: 0;
animation: pulse 1.6s ease-in-out infinite;
}
.vt-now-line {
flex: 1;
height: 2px;
background: rgb(239, 68, 68);
box-shadow: 0 0 6px rgba(239, 68, 68, 0.5);
margin-left: 2px;
}
/* ─── Pulse no "Agora" ─────────────────────────────────────── */
.pulse-dot {
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes 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);
}
.settings-pop-enter-active,
.settings-pop-leave-active {
transition: opacity 160ms ease, transform 160ms ease;
}
.settings-pop-enter-from,
.settings-pop-leave-to {
opacity: 0;
transform: translateY(-6px) scale(0.98);
}
/* line-clamp util (caso Tailwind não tenha) */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
<!--
Variáveis de tema Melissa bloco GLOBAL (não scoped) pra
atravessar o scoped CSS dos componentes filhos (MelissaMenu,
MelissaCronometro, MelissaAgenda, MelissaCard, MelissaBusca).
Defaults (modo escuro):
--m-bg-soft bg sutil em superfícies internas (cards, hovers)
--m-bg-medium bg dos painéis flutuantes (panel principal)
--m-text cor de texto principal
--m-text-muted texto secundário
--m-border cor de bordas glass
--m-accent cor primária do tema (PrimeVue --p-primary-color)
--m-accent-soft tint sutil da primária pra active/hover
Em modo light (html sem .app-dark) os valores invertem.
-->
<style>
/* Tokens Melissa derivados dos tokens semânticos do PrimeVue (--p-*) que
já flipam automaticamente com dark/light AND seguem o preset escolhido
(Aura/Lara/Nora/Material têm escalas e radii próprios). */
.win11-root {
/* Cor primária — vem do preset escolhido pelo user */
--m-accent: var(--p-primary-color);
--m-accent-soft: color-mix(in srgb, var(--p-primary-color) 18%, transparent);
--m-accent-strong: color-mix(in srgb, var(--p-primary-color) 35%, transparent);
/* Glass derivado do content/surface tokens com alpha pra preservar
o efeito translúcido sobre a photo. Auto-flip dark/light. */
--m-bg-soft: color-mix(in srgb, var(--p-content-background) 50%, transparent);
--m-bg-soft-hover: color-mix(in srgb, var(--p-content-hover-background, var(--p-surface-100)) 70%, transparent);
--m-bg-medium: color-mix(in srgb, var(--p-content-background) 88%, transparent);
/* Texto e bordas — auto-flip via PrimeVue */
--m-text: var(--p-text-color);
--m-text-muted: var(--p-text-muted-color);
--m-text-faint: color-mix(in srgb, var(--p-text-color) 45%, transparent);
--m-border: var(--p-content-border-color);
--m-border-strong: color-mix(in srgb, var(--p-text-color) 22%, transparent);
/* Border-radius do preset (Aura usa 12px, Nora 6px, Lara 6px etc.) */
--m-radius: var(--p-border-radius, 8px);
--m-radius-lg: calc(var(--p-border-radius, 8px) * 1.5);
/* RGB do dim (overlay sobre a photo). Opacidade vem do slider via
rgba(var(--m-dim-rgb), opacity) no inline style. Único token que
precisa de override manual (PrimeVue não tem "dim" nativo). */
--m-dim-rgb: 0, 0, 0;
/* ─── Gradiente "Bloom" (default = dark) ────────────────────────
Derivado da palette primária + surface do preset. Em light flipam
pra tons claros (200/100 + surface-0/primary-50). O defaultBgStyle
no <script> referencia essas vars; mudar aqui muda o bloom todo. */
--bloom-c1: var(--p-primary-400);
--bloom-c2: var(--p-primary-300);
--bloom-base-1: var(--p-surface-950);
--bloom-base-2: var(--p-primary-950);
/* ─── Pílula de atalho de teclado (psi-kbd) ─────────────────────
Em dark: pílula escura com texto claro. Em light: pílula clara
com texto escuro (override no bloco light abaixo). */
--m-kbd-bg: rgba(0, 0, 0, 0.65);
--m-kbd-text: rgba(255, 255, 255, 0.92);
/* ─── Dock (taskbar transparent) ────────────────────────────────
Altura da faixa inferior que recebe items minimizados (chip do
cronômetro, futuros). Páginas fullscreen reservam esse espaço
no inset bottom pra não ficar atrás do dock. */
--m-dock-h: 76px;
}
/* ─── Dock (global pra atravessar Teleport + evitar perda de scoped
em static-hoisted nodes). Faixa horizontal no bottom. Sem bg.
Items individuais (chip cronômetro, dock-actions, futuros) têm
visuais próprios e posicionam-se dentro do flexbox. */
.melissa-dock {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: var(--m-dock-h, 76px);
z-index: 65; /* abaixo do ψ (70) e overlays modais */
display: flex;
align-items: center;
/* padding-left reserva ~96px pro ψ (56px + 1.25rem left + gap) */
padding: 0 1.5rem 0 6rem;
gap: 12px;
pointer-events: none; /* só os items recebem clique */
}
.melissa-dock > * { pointer-events: auto; }
/* ════════════════════════════════════════════════════════════════
LIGHT MODE — aesthetic "Pippit/Linear": superfícies sólidas,
bordas finas, sombras suaves. Sem photo nem glass blur.
Texto SEMPRE legível porque vem do --p-text-color sobre superfície
sólida (não depende do slider de opacidade nem da photo).
════════════════════════════════════════════════════════════════ */
html:not(.app-dark) .win11-root {
/* Surfaces sólidas (sem alpha) — vem dos tokens PrimeVue */
--m-bg-soft: var(--p-surface-100, #f3f4f6);
--m-bg-soft-hover: var(--p-surface-200, #e5e7eb);
--m-bg-medium: var(--p-content-background); /* opaco */
/* Bordas mais sutis em light (Pippit-style) */
--m-border: var(--p-content-border-color);
--m-border-strong: color-mix(in srgb, var(--p-text-color) 14%, transparent);
/* Sombras leves (substituem o glass blur em light) */
--m-shadow: 0 1px 3px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.05);
--m-shadow-md: 0 2px 6px rgba(0, 0, 0, 0.06), 0 8px 24px rgba(0, 0, 0, 0.08);
/* Dim só aparece quando há foto custom (ver regra .win11-has-photo
no fim do arquivo). Sem foto, o bloom claro já é leve o suficiente. */
--m-dim-rgb: 255, 255, 255;
/* ─── Gradiente "Bloom" — versão clara (Pippit/Linear morning) ────
Tons 50/100/200 da primary + surface-0/50. Gradiente fica suave,
legível, dá identidade Win11 sem competir com o conteúdo. */
--bloom-c1: var(--p-primary-200);
--bloom-c2: var(--p-primary-100);
--bloom-base-1: var(--p-surface-0, #ffffff);
--bloom-base-2: var(--p-primary-50);
/* Pílula psi-kbd em light: bg escuro suave + texto branco
(NÃO usar var(--m-text) aqui — em light vira preto e fica preto sobre preto). */
--m-kbd-bg: color-mix(in srgb, var(--p-text-color) 78%, transparent);
--m-kbd-text: var(--p-content-background);
}
/* Light: dim escondido por padrão (sem foto, bloom claro já é leve).
Quando o user faz upload de foto custom, .win11-has-photo é adicionada
e o dim volta a fazer sentido. */
html:not(.app-dark) .win11-root .win11-dim {
display: none;
}
html:not(.app-dark) .win11-root.win11-has-photo .win11-dim {
display: block;
}
/* Light: panels viram OPACOS com borda + shadow (sem backdrop-filter).
Aplica em todos os "glass-*" e cards Melissa via :is(). */
html:not(.app-dark) .win11-root :is(
.glass-panel,
.glass-btn,
.mc-card,
.mc-card-add,
.mc-card__go,
.mc-fc-event,
.mb-field,
.mb-panel,
.mb-item,
.mc-panel,
.mm-panel,
.secao-panel,
.central-panel,
.evento-panel,
.ma-page,
.ma-w,
.ma-sess,
.ma-ev,
.crono-icon-btn,
.psi-btn,
.psi-kbd,
.crono-chip,
.resumo-link,
.filtro-chip,
.module-tile,
.settings-select,
.settings-test-btn
) {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* Sombra suave nos painéis principais em light (substitui o "depth" do blur) */
html:not(.app-dark) .win11-root :is(
.glass-panel,
.mc-card,
.mc-panel,
.mm-panel,
.secao-panel,
.central-panel,
.evento-panel,
.ma-page,
.ma-w,
.mb-panel
) {
box-shadow: var(--m-shadow);
}
html:not(.app-dark) .win11-root :is(
.mm-panel,
.secao-panel,
.central-panel,
.evento-panel,
.mc-panel,
.ma-page
) {
box-shadow: var(--m-shadow-md);
}
/* ─── Override de classes Tailwind hardcoded (`text-white/X`, `bg-white/X`,
`border-white/X`) que vêm dos templates. Em light: textos viram dark,
bgs viram surface tokens. */
html:not(.app-dark) .win11-root .text-white,
html:not(.app-dark) .win11-root [class*="text-white/9"],
html:not(.app-dark) .win11-root [class*="text-white/85"],
html:not(.app-dark) .win11-root [class*="text-white/8"] {
color: var(--m-text);
}
html:not(.app-dark) .win11-root [class*="text-white/7"],
html:not(.app-dark) .win11-root [class*="text-white/6"],
html:not(.app-dark) .win11-root [class*="text-white/55"],
html:not(.app-dark) .win11-root [class*="text-white/5"] {
color: var(--m-text-muted);
}
html:not(.app-dark) .win11-root [class*="text-white/4"],
html:not(.app-dark) .win11-root [class*="text-white/3"],
html:not(.app-dark) .win11-root [class*="text-white/2"] {
color: var(--m-text-faint);
}
html:not(.app-dark) .win11-root [class*="bg-white/"] {
background-color: var(--m-bg-soft);
}
html:not(.app-dark) .win11-root [class*="bg-white/2"],
html:not(.app-dark) .win11-root [class*="bg-white/3"] {
background-color: var(--m-bg-soft-hover);
}
html:not(.app-dark) .win11-root [class*="hover:bg-white/"]:hover {
background-color: var(--m-bg-soft-hover) !important;
}
html:not(.app-dark) .win11-root [class*="border-white/"] {
border-color: var(--m-border);
}
html:not(.app-dark) .win11-root [class*="border-white/2"],
html:not(.app-dark) .win11-root [class*="border-white/3"] {
border-color: var(--m-border-strong);
}
/* Light: clock e ψ usam color: white hardcoded (CSS scoped, fora do alcance
dos overrides Tailwind). Sobre o bloom claro, branco vira invisível —
forçar var(--m-text) e shadow leve. */
html:not(.app-dark) .win11-root .clock-display {
color: var(--m-text);
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.6);
}
html:not(.app-dark) .win11-root .psi-glyph {
color: var(--m-text);
text-shadow: none;
}
/* Light: psi-btn ganha sombra suave (substitui a preta agressiva 24px)
+ um leve halo da primary pra destacar o botão sobre o bloom claro. */
html:not(.app-dark) .win11-root .psi-btn {
box-shadow:
0 4px 14px rgba(15, 23, 42, 0.08),
0 1px 3px rgba(15, 23, 42, 0.06),
0 0 0 1px color-mix(in srgb, var(--p-primary-color) 12%, transparent);
}
html:not(.app-dark) .win11-root .psi-btn:hover {
box-shadow:
0 8px 20px rgba(15, 23, 42, 0.12),
0 2px 6px rgba(15, 23, 42, 0.08),
0 0 0 1px color-mix(in srgb, var(--p-primary-color) 24%, transparent);
}
/* Light + bloom: panels com leve transparência pra deixar a cor do bloom
transparecer, mas mantendo legibilidade. 92% de opacidade em vez de 100%. */
html:not(.app-dark) .win11-root :is(
.glass-panel,
.mc-card,
.mc-panel,
.mm-panel,
.secao-panel,
.central-panel,
.evento-panel,
.ma-page,
.ma-w,
.mb-panel
) {
background-color: color-mix(in srgb, var(--p-content-background) 92%, transparent);
}
/* ─── Ícones com tons claros (Tailwind text-X-300/400) ────────
Tons claros são pensados pra fundo escuro (dark glass). Em light
ficam invisíveis sobre bloom claro/panels brancos. Mapeia pra
tons -600 (legível em fundo claro, mantém identidade da cor). */
html:not(.app-dark) .win11-root .text-emerald-200,
html:not(.app-dark) .win11-root .text-emerald-300,
html:not(.app-dark) .win11-root .text-emerald-400 { color: rgb(5 150 105); } /* emerald-600 */
html:not(.app-dark) .win11-root .text-green-200,
html:not(.app-dark) .win11-root .text-green-300,
html:not(.app-dark) .win11-root .text-green-400 { color: rgb(22 163 74); } /* green-600 */
html:not(.app-dark) .win11-root .text-amber-200,
html:not(.app-dark) .win11-root .text-amber-300,
html:not(.app-dark) .win11-root .text-amber-400 { color: rgb(217 119 6); } /* amber-600 */
html:not(.app-dark) .win11-root .text-purple-200,
html:not(.app-dark) .win11-root .text-purple-300,
html:not(.app-dark) .win11-root .text-purple-400 { color: rgb(147 51 234); } /* purple-600 */
html:not(.app-dark) .win11-root .text-blue-200,
html:not(.app-dark) .win11-root .text-blue-300,
html:not(.app-dark) .win11-root .text-blue-400 { color: rgb(37 99 235); } /* blue-600 */
html:not(.app-dark) .win11-root .text-sky-200,
html:not(.app-dark) .win11-root .text-sky-300,
html:not(.app-dark) .win11-root .text-sky-400 { color: rgb(2 132 199); } /* sky-600 */
html:not(.app-dark) .win11-root .text-cyan-200,
html:not(.app-dark) .win11-root .text-cyan-300,
html:not(.app-dark) .win11-root .text-cyan-400 { color: rgb(8 145 178); } /* cyan-600 */
html:not(.app-dark) .win11-root .text-teal-200,
html:not(.app-dark) .win11-root .text-teal-300,
html:not(.app-dark) .win11-root .text-teal-400 { color: rgb(13 148 136); } /* teal-600 */
html:not(.app-dark) .win11-root .text-indigo-200,
html:not(.app-dark) .win11-root .text-indigo-300,
html:not(.app-dark) .win11-root .text-indigo-400 { color: rgb(79 70 229); } /* indigo-600 */
html:not(.app-dark) .win11-root .text-violet-200,
html:not(.app-dark) .win11-root .text-violet-300,
html:not(.app-dark) .win11-root .text-violet-400 { color: rgb(124 58 237); } /* violet-600 */
html:not(.app-dark) .win11-root .text-pink-200,
html:not(.app-dark) .win11-root .text-pink-300,
html:not(.app-dark) .win11-root .text-pink-400 { color: rgb(219 39 119); } /* pink-600 */
html:not(.app-dark) .win11-root .text-rose-200,
html:not(.app-dark) .win11-root .text-rose-300,
html:not(.app-dark) .win11-root .text-rose-400 { color: rgb(225 29 72); } /* rose-600 */
html:not(.app-dark) .win11-root .text-red-200,
html:not(.app-dark) .win11-root .text-red-300,
html:not(.app-dark) .win11-root .text-red-400 { color: rgb(220 38 38); } /* red-600 */
html:not(.app-dark) .win11-root .text-orange-200,
html:not(.app-dark) .win11-root .text-orange-300,
html:not(.app-dark) .win11-root .text-orange-400 { color: rgb(234 88 12); } /* orange-600 */
html:not(.app-dark) .win11-root .text-yellow-200,
html:not(.app-dark) .win11-root .text-yellow-300,
html:not(.app-dark) .win11-root .text-yellow-400 { color: rgb(202 138 4); } /* yellow-600 */
html:not(.app-dark) .win11-root .text-fuchsia-200,
html:not(.app-dark) .win11-root .text-fuchsia-300,
html:not(.app-dark) .win11-root .text-fuchsia-400 { color: rgb(192 38 211); } /* fuchsia-600 */
html:not(.app-dark) .win11-root .text-lime-200,
html:not(.app-dark) .win11-root .text-lime-300,
html:not(.app-dark) .win11-root .text-lime-400 { color: rgb(101 163 13); } /* lime-600 */
</style>