Files
agenciapsilmno/src/layout/melissa/MelissaLayout.vue
T
Leonardo 9966b5f175 Melissa: paginas nativas cfg-* + temas + textos com fundo + drawer WA
CHROME COMPARTILHADO + 18 PAGINAS NATIVAS
- MelissaConfigPage: chrome unico (header, drawer mobile, sidebar com Configuracoes
  + FAQ slot, main com Suspense). Replica fake-dialog right rule e fica flush
  com o config-aside global.
- 18 wrappers finos (~25 linhas cada): cfg-precificacao, cfg-descontos,
  cfg-excecoes, cfg-convenios, cfg-wa, cfg-wa-pessoal, cfg-wa-oficial,
  cfg-wa-templates, cfg-conversas-tags/autoreply/optouts/sla/bots,
  cfg-lembretes, cfg-creditos-wa, cfg-sms, cfg-email-templates,
  cfg-recursos-extras, cfg-recursos-extras-extrato, cfg-auditoria.
- Cada wrapper usa defineAsyncComponent + Suspense pra evitar race com
  tenantStore no boot (loading travado em alguns chooser-style pages).
- MelissaLayout: imports + SECOES + MELISSA_NON_CONFIG_SLUGS + render
  conditions atualizados pra cobrir os 18 slugs.

PAGINAS LEGADAS DETECTAM CONTEXTO MELISSA
- ConfiguracoesWhatsappChooserPage, WhatsappPage, TwilioWhatsappPage,
  SmsPage, RecursosExtrasPage, EmailTemplatesPage, AddonsExtratoPage,
  AgendadorPage, ConversasAutoreplyPage: route.startsWith('/melissa')
  decide se router.push vai pro slug Melissa ou /configuracoes legado.
- Anchors <a href="/configuracoes/..."> (que recarregavam pagina e
  vazavam o usuario do Melissa) trocados por RouterLink context-aware.
- MelissaAgenda.goSettings agora vai pra /melissa/agenda-config.

PERSONALIZAR > TEMAS
- melissaThemes.js: catalogo Freud/Klein/Jung (wallpaper + cor primaria
  + preset Lara/Nora + surface).
- Toggle de tema aplica tudo de uma vez; persistido em melissa_prefs.themeName.
- Boot resolve themeName -> imagem via fetch + data URL (sem guardar
  data URL gigante no DB).
- onCustomFileChange/onClearBg invalidam themeName quando user mexe no bg.

PERSONALIZAR > FUNDO NOS TEXTOS
- Pref textBgEnabled em melissa_prefs.
- MelissaHeroClock: prop textBg envolve relogio/data/saudacao/resumo
  em <span class="hero-text"> que ganha bg branco/preto 60% + borda
  + padding + radius quando o toggle esta on.
- Vars --m-hero-text-bg / --m-hero-text-border flipam com light/dark.

TOP + DOCK COM GRADIENT HORIZONTAL
- Var --m-band: preto 80% (dark) / branco 80% (light).
- .melissa-topbar-band: gradiente cor->transparente (right->left) atras
  dos botoes do topo.
- .melissa-dock: gradiente cor->transparente (left->right) atras dos pins.

MELISSANEGOCIO ABSORVE MINHA EMPRESA
- Adiciona logo upload + preview "cartao de visita" (computeds
  enderecoLinhas/redesValidas/temDados/logoDisplay + redeIcon helper).
- Normaliza dados legados do cfg-empresa: redes_sociais.{rede} virou {name}.
- Preview teleporta entre 3 destinos baseado no viewport:
  mobile -> drawer; mid-desktop -> sidebar; wide-desktop (>=1340px) ->
  painel flutuante FORA do fake dialog (ancora no right edge + 14px gap,
  altura segue conteudo, header alinhado com header do dialog).
- Remove cfg-empresa de melissaConfigGrupos.js + COMPONENT_MAP do
  MelissaConfiguracoes; grupo "Empresa & Plataforma" -> "Plataforma".

CRONOMETRO -> SESSAO AGENDADA
- MelissaCronometro emite session-end ao parar com paciente selecionado
  (threshold 5s pra ignorar start/stop acidental).
- MelissaLayout.onCronometroSessionEnd busca agenda_eventos do paciente
  no dia (tipo='sessao'), pega o mais recente e grava em
  extra_fields.cronometro_duracao_seg + cronometro_parado_em.
- Toast: sucesso ("X min salvos") ou warn ("sessao nao encontrada").

CONVERSATIONDRAWER WHATSAPP-LIKE
- Nova imagem whatsapp-bg.jpg (renomeada de hash random) usada como
  tile (380px) no .cd-msgs.
- Light: bege #efeae2 + multiply blend.
- Dark: #0b141a + camada 78% sobre o doodle.
- Bubbles WA-style (verde out / branco-dark in com tails) ja existiam.

EXTRATO RECURSOS EXTRAS
- Filtros 2-por-linha em Melissa (vs 1/4 no /configuracoes).
- Cards de Resumo teleportam pro #cfg-page-side em Melissa
  (1-col empilhado no drawer; 4-col inline no /configuracoes).
- Botoes de exportar com flex-1 distribuidos em uma unica linha em
  desktop, wrap no mobile.
- DataTable scrollable em ambos os layouts.

OUTROS AJUSTES MENORES
- Cfg-conversas-autoreply: dias semana 4-cols em Melissa (vs 7-cols
  no /configuracoes).
- Cfg-creditos-wa: 1/2 por linha (vs 1/2/4) em Melissa.
- Cfg-recursos-extras: pacotes 1/2 (vs 1/2/4); "Em breve" 1-col.
- WhatsAppPage aba Templates: guia de formatacao teleporta pro side
  drawer em Melissa, deixando textareas full-width.
- ConfigPage chrome agora tem #cfg-page-actions target pros Teleport
  de acoes (refresh button etc).
- Imagens renomeadas em src/assets/themes/ (freudwebp/melainewebp/
  jungwebp.webp) e src/assets/whatsapp-bg.jpg.
- JoditTextEditor.vue novo (wrapper Jodit generico, sem features de email).
- MelissaConfigList.vue novo (lista compartilhada de configs pro drawer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 23:48:18 -03:00

3184 lines
137 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: /melissa (com :secao? opcional). User com layout_variant='melissa'
* em user_settings e' redirecionado da home do role pra ca pelo router.
*/
import { ref, computed, watch, onMounted, onBeforeUnmount, provide, nextTick } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { useLayout } from '@/layout/composables/layout';
import { applyThemeEngine, surfaces as THEME_SURFACES, presetOptions as THEME_PRESETS } from '@/theme/theme.options';
import { MELISSA_THEME_NAMES, findMelissaTheme } from './melissaThemes';
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 MelissaSettingsPanel from './MelissaSettingsPanel.vue';
import MelissaHeroClock from './MelissaHeroClock.vue';
import MelissaTimelineHoje from './MelissaTimelineHoje.vue';
import { useMelissaWallpaper } from './composables/useMelissaWallpaper';
import MelissaAgenda from './MelissaAgenda.vue';
import MelissaPacientes from './MelissaPacientes.vue';
import MelissaCompromissos from './MelissaCompromissos.vue';
import MelissaRecorrencias from './MelissaRecorrencias.vue';
import MelissaConversas from './MelissaConversas.vue';
import MelissaTags from './MelissaTags.vue';
import MelissaGrupos from './MelissaGrupos.vue';
import MelissaConfiguracoes from './MelissaConfiguracoes.vue';
import MelissaPerfil from './MelissaPerfil.vue';
import MelissaPlano from './MelissaPlano.vue';
import MelissaNegocio from './MelissaNegocio.vue';
import MelissaAlterarPlano from './MelissaAlterarPlano.vue';
import MelissaSeguranca from './MelissaSeguranca.vue';
import MelissaBloqueios from './MelissaBloqueios.vue';
import MelissaAgendador from './MelissaAgendador.vue';
import MelissaAgendaConfig from './MelissaAgendaConfig.vue';
import MelissaPagamento from './MelissaPagamento.vue';
import MelissaPrecificacao from './MelissaPrecificacao.vue';
import MelissaDescontos from './MelissaDescontos.vue';
import MelissaExcecoes from './MelissaExcecoes.vue';
import MelissaConvenios from './MelissaConvenios.vue';
import MelissaCfgWa from './MelissaCfgWa.vue';
import MelissaCfgWaPessoal from './MelissaCfgWaPessoal.vue';
import MelissaCfgWaOficial from './MelissaCfgWaOficial.vue';
import MelissaCfgWaTemplates from './MelissaCfgWaTemplates.vue';
import MelissaCfgConversasTags from './MelissaCfgConversasTags.vue';
import MelissaCfgConversasAutoreply from './MelissaCfgConversasAutoreply.vue';
import MelissaCfgConversasOptouts from './MelissaCfgConversasOptouts.vue';
import MelissaCfgConversasSla from './MelissaCfgConversasSla.vue';
import MelissaCfgConversasBots from './MelissaCfgConversasBots.vue';
import MelissaCfgLembretes from './MelissaCfgLembretes.vue';
import MelissaCfgCreditosWa from './MelissaCfgCreditosWa.vue';
import MelissaCfgSms from './MelissaCfgSms.vue';
import MelissaCfgEmailTemplates from './MelissaCfgEmailTemplates.vue';
import MelissaCfgRecursosExtras from './MelissaCfgRecursosExtras.vue';
import MelissaCfgRecursosExtrasExtrato from './MelissaCfgRecursosExtrasExtrato.vue';
import MelissaCfgAuditoria from './MelissaCfgAuditoria.vue';
// Sidebar global de configs removido — substituido por botao + popover
// (MelissaConfigPopover) dentro de cada pagina de config. Resolveu lag
// de scroll que o overlay sempre visivel causava em mobile.
import MelissaEmbed from './MelissaEmbed.vue';
import MelissaCadastrosRecebidos from './MelissaCadastrosRecebidos.vue';
import MelissaAgendamentosRecebidos from './MelissaAgendamentosRecebidos.vue';
import MelissaLinkExterno from './MelissaLinkExterno.vue';
import MelissaNotificacoes from './MelissaNotificacoes.vue';
import MelissaFinanceiro from './MelissaFinanceiro.vue';
import MelissaFinanceiroLancamentos from './MelissaFinanceiroLancamentos.vue';
import MelissaDocumentos from './MelissaDocumentos.vue';
import MelissaDocumentosTemplates from './MelissaDocumentosTemplates.vue';
import MelissaRelatorios from './MelissaRelatorios.vue';
import MelissaMedicos from './MelissaMedicos.vue';
import MelissaEventoPanel from './MelissaEventoPanel.vue';
import { TOQUE_IDS, useMelissaToques } from './composables/useMelissaToques';
import { useMelissaPacientes } from './composables/useMelissaPacientes';
import { useMelissaEventosHoje } from './composables/useMelissaEventos';
import { useMelissaWhatsapp } from './composables/useMelissaWhatsapp';
import { useMelissaAgenda, MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
import { useMelissaDockPins } from './composables/useMelissaDockPins';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
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';
// Topbar system actions trazidos do AppTopbar pra Melissa: plan switcher
// (DEV), notificações e ajuda. AppTopbar não monta na rota /melissa
// (fullscreen), então duplicamos os triggers + drawers aqui.
import NotificationDrawer from '@/components/notifications/NotificationDrawer.vue';
import { useNotifications } from '@/composables/useNotifications';
import { useNotificationStore } from '@/stores/notificationStore';
import { useAjuda } from '@/composables/useAjuda';
import { useTopbarPlanMenu } from '@/composables/useTopbarPlanMenu';
// Pacientes + eventos do dia.
//
// PERF: quando o usuário entra direto numa seção (`/melissa/agenda`,
// `/melissa/pacientes`...), o resumo fica tapado pelo conteúdo da seção.
// Carregar os dois loaders na hora competiria com as queries da seção
// (que é o que o user efetivamente vai ver). Adiamos os loaders do
// resumo pra rodarem em background via `requestIdleCallback` quando a
// rota inicial já tem seção. Quando o user fecha a seção (volta pro
// resumo), o cache provavelmente já tá quente — se não estiver, o
// loading aparece naturalmente.
//
// CACHE: composables usam stale-while-revalidate via melissaCacheStore.
// Reabertura do Melissa na mesma sessão SPA é instantânea.
// Snapshot da rota no setup pra detectar deep-link com seção já no boot.
// (`route` reativo é declarado mais abaixo, mas só precisamos do params
// inicial aqui — `setup` roda 1× por mount, params do router já estão
// resolvidos nesse ponto.)
const _hasInitialSecao = !!useRoute().params?.secao;
const {
pacientes: pacientesReais,
loading: pacientesLoading,
refetch: refetchPacientes,
fetchCached: fetchPacientesCached
} = useMelissaPacientes({ autoFetch: !_hasInitialSecao });
const {
eventos: eventosHojeReais,
refetch: refetchEventosHoje,
fetchCached: fetchEventosHojeCached
} = useMelissaEventosHoje({ autoFetch: !_hasInitialSecao });
// Defer manual quando a rota inicial é uma seção: agenda os fetches do
// resumo pra rodarem após a seção montar (idle callback) — fetchCached
// usa stale-while-revalidate, então não tomba o cache. setTimeout 200
// como fallback pra navegadores sem requestIdleCallback (Safari < 16).
if (_hasInitialSecao) {
const _idleFetch = () => {
fetchPacientesCached();
fetchEventosHojeCached();
};
if (typeof window !== 'undefined') {
if (typeof window.requestIdleCallback === 'function') {
window.requestIdleCallback(_idleFetch, { timeout: 1500 });
} else {
setTimeout(_idleFetch, 200);
}
}
}
// ───────────────────────────────────────────────────────────────
// 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: false },
{ 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.' },
// Pagina unificada do Layout Melissa (Aparencia + Fundo + Relogio + Cronometro)
aparencia: { label: 'Layout Melissa', icon: 'pi pi-palette', descricao: 'Aparência, plano de fundo, relógio e cronômetro do resumo.' },
// Pagina nativa do perfil (MelissaPerfil) — saiu do MelissaConfiguracoes
perfil: { label: 'Meu Perfil', icon: 'pi pi-user', descricao: 'Identidade, contato, bio, redes — gamificacao no aside.' },
// Pagina nativa do plano (MelissaPlano) — saiu do MelissaConfiguracoes
plano: { label: 'Meu Plano', icon: 'pi pi-credit-card', descricao: 'Assinatura, recursos liberados e historico de mudancas.' },
// Pagina nativa do negocio (MelissaNegocio) — saiu do MelissaConfiguracoes
negocio: { label: 'Meu Negócio', icon: 'pi pi-briefcase', descricao: 'Identidade, fiscal, endereco, contato, redes — gamificacao no aside.' },
// Pagina nativa de seguranca (MelissaSeguranca) — saiu do MelissaConfiguracoes
seguranca: { label: 'Segurança', icon: 'pi pi-shield', descricao: 'Trocar senha + boas praticas + estado da sessao.' },
// Pagina nativa de bloqueios (MelissaBloqueios) — saiu do MelissaConfiguracoes
bloqueios: { label: 'Bloqueios e Feriados', icon: 'pi pi-ban', descricao: 'Feriados nacionais (auto), municipais e bloqueios manuais.' },
// Pagina nativa de configs da agenda (MelissaAgendaConfig) — saiu do MelissaConfiguracoes
'agenda-config': { label: 'Configurações da Agenda', icon: 'pi pi-calendar', descricao: 'Jornada (dias e horários), ritmo das sessões e agendamento online.' },
// Pagina nativa de formas de pagamento (MelissaPagamento) — saiu do MelissaConfiguracoes
pagamento: { label: 'Formas de Pagamento', icon: 'pi pi-wallet', descricao: 'Pix, depósito, dinheiro, cartão e convênio.' },
// Paginas nativas de configuracoes financeiras — saidas do MelissaConfiguracoes
'cfg-precificacao': { label: 'Precificação', icon: 'pi pi-tag', descricao: 'Valor padrão da sessão e preços por tipo de compromisso.' },
'cfg-descontos': { label: 'Descontos por Paciente', icon: 'pi pi-percentage', descricao: 'Descontos recorrentes aplicados automaticamente.' },
'cfg-excecoes': { label: 'Exceções Financeiras', icon: 'pi pi-exclamation-triangle', descricao: 'O que cobrar em faltas, cancelamentos e situações excepcionais.' },
'cfg-convenios': { label: 'Convênios', icon: 'pi pi-id-card', descricao: 'Cadastre os convênios que você atende e seus valores.' },
'cfg-wa': { label: 'Canal WhatsApp', icon: 'pi pi-whatsapp', descricao: 'Escolha o canal de envio: oficial AgenciaPSI ou pessoal.' },
'cfg-wa-pessoal': { label: 'WhatsApp Pessoal', icon: 'pi pi-mobile', descricao: 'Conecte seu próprio número via QR code.' },
'cfg-wa-oficial': { label: 'WhatsApp Oficial', icon: 'pi pi-verified', descricao: 'Número provisionado pela AgenciaPSI via API oficial Meta.' },
'cfg-wa-templates': { label: 'Templates WhatsApp', icon: 'pi pi-file-edit', descricao: 'Personalize os textos enviados ou volte ao padrão.' },
'cfg-conversas-tags': { label: 'Tags de Conversa', icon: 'pi pi-tag', descricao: 'Etiquetas custom pra classificar threads no CRM.' },
'cfg-conversas-autoreply': { label: 'Auto-reply WhatsApp', icon: 'pi pi-reply', descricao: 'Resposta automática quando paciente escreve fora do horário.' },
'cfg-conversas-optouts': { label: 'Opt-outs (LGPD)', icon: 'pi pi-ban', descricao: 'Números que pediram pra não receber mensagens. LGPD Art. 18.' },
'cfg-conversas-sla': { label: 'SLA de resposta', icon: 'pi pi-stopwatch', descricao: 'Tempo máximo pra responder. Alerta quando estourar.' },
'cfg-conversas-bots': { label: 'Bot de triagem', icon: 'pi pi-android', descricao: 'Coleta nome e motivo via WhatsApp antes do humano.' },
'cfg-lembretes': { label: 'Lembretes de Sessão', icon: 'pi pi-bell', descricao: 'WhatsApp automático antes das sessões agendadas.' },
'cfg-creditos-wa': { label: 'Créditos WhatsApp', icon: 'pi pi-credit-card', descricao: 'Compre pacotes de mensagens, veja saldo e extrato.' },
'cfg-sms': { label: 'SMS', icon: 'pi pi-comment', descricao: 'Backup quando WhatsApp falha. Gerencie créditos SMS.' },
'cfg-email-templates': { label: 'Templates de E-mail', icon: 'pi pi-envelope', descricao: 'Personalize os e-mails enviados aos pacientes.' },
'cfg-recursos-extras': { label: 'Recursos Extras', icon: 'pi pi-box', descricao: 'Amplie as funcionalidades com recursos adicionais.' },
'cfg-recursos-extras-extrato': { label: 'Extrato de Recursos Extras', icon: 'pi pi-list', descricao: 'Histórico de débitos e créditos exportável.' },
'cfg-auditoria': { label: 'Auditoria', icon: 'pi pi-shield', descricao: 'Registro imutável de operações (LGPD Art. 37).' },
// Pagina nativa de alterar plano (MelissaAlterarPlano) — substitui /therapist/upgrade
'alterar-plano': { label: 'Alterar Plano', icon: 'pi pi-arrow-up-right', descricao: 'Escolha um plano pessoal pra ativar todos os recursos.' },
// Onda 1 — pages embedadas via MelissaEmbed (1-coluna, hero glass)
'financeiro': { label: 'Financeiro', icon: 'pi pi-wallet', descricao: 'Visão geral, recebíveis e indicadores.' },
'financeiro-lancamentos': { label: 'Lançamentos financeiros', icon: 'pi pi-list', descricao: 'Cobranças, pagamentos e recebimentos.' },
'documentos': { label: 'Documentos', icon: 'pi pi-file', descricao: 'Documentos clínicos do tenant.' },
'documentos-templates': { label: 'Templates de documentos', icon: 'pi pi-file-edit', descricao: 'Modelos pra prontuários e relatórios.' },
'agendamentos-recebidos': { label: 'Agendamentos recebidos', icon: 'pi pi-inbox', descricao: 'Solicitações do agendador online.' },
'online-scheduling': { label: 'Agendador online', icon: 'pi pi-calendar-clock', descricao: 'Configure o link publico, identidade visual, fluxo, pagamento e textos do agendador.' },
'relatorios': { label: 'Relatórios', icon: 'pi pi-chart-bar', descricao: 'Indicadores clínicos e financeiros do tenant.' },
'notificacoes': { label: 'Notificações', icon: 'pi pi-bell', descricao: 'Histórico de notificações enviadas.' },
'link-externo': { label: 'Link externo de cadastro', icon: 'pi pi-share-alt', descricao: 'Link público pra pacientes se cadastrarem.' }
};
// Set de keys que renderizam via MelissaEmbed (Onda 1 — pages 1-coluna).
// Todas foram promovidas pra paginas nativas Melissa.
const MELISSA_EMBED_KEYS = [];
// Slugs reservados pra páginas dedicadas (não-config) — agenda, pacientes,
// conversas etc. + as MELISSA_EMBED_KEYS. Usado no isConfigRoute pra
// evitar colisão (ex: /melissa/agenda → MelissaAgenda, não config).
const MELISSA_NON_CONFIG_SLUGS = new Set([
'agenda', 'pacientes', 'compromissos', 'recorrencias', 'conversas',
'tags', 'grupos', 'cadastros-recebidos', 'medicos', 'agendamentos-recebidos',
'link-externo', 'notificacoes', 'financeiro', 'financeiro-lancamentos',
'documentos', 'documentos-templates', 'relatorios',
'perfil', 'plano', 'negocio', 'seguranca', 'bloqueios', 'alterar-plano',
'online-scheduling', 'agenda-config', 'pagamento',
'cfg-precificacao', 'cfg-descontos', 'cfg-excecoes', 'cfg-convenios',
'cfg-wa', 'cfg-wa-pessoal', 'cfg-wa-oficial', 'cfg-wa-templates',
'cfg-conversas-tags', 'cfg-conversas-autoreply', 'cfg-conversas-optouts',
'cfg-conversas-sla', 'cfg-conversas-bots',
'cfg-lembretes', 'cfg-creditos-wa', 'cfg-sms',
'cfg-email-templates', 'cfg-recursos-extras', 'cfg-recursos-extras-extrato', 'cfg-auditoria',
...MELISSA_EMBED_KEYS
]);
// Aliases "bonitos" + INLINE_KEYS reconhecidos pelo MelissaConfiguracoes.
// Mantidos sincronizados com o ROUTE_ALIASES/INLINE_KEYS de lá.
const MELISSA_CONFIG_ALIASES = new Set([
'aparencia',
'fundo', 'relogio', 'cronometro'
]);
function isMelissaConfigRoute(slug) {
if (!slug) return false;
const s = String(slug).toLowerCase();
if (MELISSA_NON_CONFIG_SLUGS.has(s)) return false;
if (MELISSA_CONFIG_ALIASES.has(s)) return true;
// Slugs diretos: cfg-precificacao, cfg-agenda, cfg-bloqueios, etc.
return s.startsWith('cfg-');
}
// Seção ativa = param `:secao?` da rota. URL é a fonte da verdade pra
// permitir back/forward e deep-link (ex: /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;
if (!s) return null;
// Aceita: keys conhecidas em SECOES (páginas dedicadas + 5 aliases legados)
if (SECOES[s]) return s;
// Aceita: rotas de config (fundo/relogio/cronometro inline e qualquer
// cfg-* embed). Sem isso, /melissa/fundo viraria null e fecharia a
// página de configs ao clicar nesses itens da sidebar.
if (isMelissaConfigRoute(s)) return s;
return null;
});
// (Removido) showConfigSidebar / configAsideLeftStyle — o sidebar
// global de configs foi substituido pelo MelissaConfigPopover
// dentro de cada pagina, ancorado no botao "Configuracoes" no
// topo da .xxx-side. Sem mais CSS var de left dinamico nas pages.
// Quando o usuário fecha a seção e volta pro resumo, garante que os
// dados estão prontos (caso o idle callback ainda não tenha disparado
// no fluxo deep-link). fetchCached é idempotente: cache hit → instant,
// cache miss → fetch real. Sem cache, não dispara nada estranho.
watch(secaoAberta, (newVal, oldVal) => {
if (oldVal && !newVal) {
fetchPacientesCached();
fetchEventosHojeCached();
}
});
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: 'Melissa', params: { secao: key } });
}
function fecharSecao() {
if (!secaoAberta.value) return;
router.push({ name: 'Melissa', params: {} });
}
// Click "Cores do Tema" no menu principal: fecha qualquer fake dialog
// aberto (perfil/plano/negocio/seguranca/pagamento/agendador/cfg-*) e
// abre o painel Personalizar (cog top-right).
function onMenuOpenSettings() {
fecharSecao();
settingsOpen.value = true;
}
// ── Pins dinâmicos do dock (híbrido: 4 fixos + 3 MRU) ──────────
const dockPins = useMelissaDockPins();
const pinContextMenu = ref(null);
const pinContextSlug = ref('');
// Toda vez que a seção muda, registra como "recente" no dock (se não
// for builtin nem pinned). Slugs de configuração (cfg-*) também viram
// recent — útil quando o user fica navegando entre páginas de config.
watch(secaoAberta, (slug) => {
if (slug) dockPins.pushRecent(slug);
});
function openPinContextMenu(event, slug) {
event.preventDefault();
event.stopPropagation();
pinContextSlug.value = slug;
pinContextMenu.value?.show(event);
}
const pinContextMenuItems = computed(() => {
const slug = pinContextSlug.value;
if (!slug) return [];
const isPinned = dockPins.isPinned(slug);
const items = [];
if (isPinned) {
items.push({
label: 'Desafixar',
icon: 'pi pi-bookmark',
command: () => dockPins.unpin(slug)
});
} else {
items.push({
label: 'Fixar no dock',
icon: 'pi pi-bookmark-fill',
command: () => {
const result = dockPins.pin(slug);
if (!result.ok && result.reason === 'full') {
toast.add({
severity: 'warn',
summary: 'Limite atingido',
detail: `Você pode fixar até ${dockPins.MAX_PINNED} atalhos. Desafixe um pra liberar espaço.`,
life: 3500
});
}
}
});
}
items.push({ separator: true });
items.push({
label: 'Remover do dock',
icon: 'pi pi-times',
command: () => dockPins.remove(slug)
});
return items;
});
// Resolve label/ícone a partir do slug pra renderizar no pin.
// Usa o catálogo SECOES (já existente) — fallback genérico se slug
// for de uma rota cfg-* (configurações embeds).
function pinMeta(slug) {
const fromSecoes = SECOES[slug];
if (fromSecoes) return { label: fromSecoes.label, icon: fromSecoes.icon };
if (slug?.startsWith('cfg-')) {
return { label: 'Configuração', icon: 'pi pi-cog' };
}
return { label: slug, icon: 'pi pi-bookmark' };
}
// 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';
// ───────────────────────────────────────────────────────────────
// Relógio (atualiza a cada segundo)
// ───────────────────────────────────────────────────────────────
const now = ref(new Date());
let clockTimer = null;
// layoutReady gate: MelissaAgenda/MelissaPacientes têm <Teleport to=".melissa-dock">
// (target vive aqui no MelissaLayout). Vue exige que o target esteja no DOM
// antes do Teleport montar. No deep-link (/melissa/agenda) os dois montam
// no mesmo render pass — race. Atrasar o mount em 1 nextTick garante que
// .melissa-dock já está no DOM. Evita "Cannot read properties of null
// (reading 'insertBefore')" em moveTeleport durante triggers reativos
// (ex: setTheme do PrimeVue) na fase de bootstrap.
const layoutReady = ref(false);
onMounted(() => {
clockTimer = setInterval(() => (now.value = new Date()), 1000);
window.addEventListener('keydown', onKeydown);
nextTick(() => { layoutReady.value = true; });
});
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 (composable)
// ───────────────────────────────────────────────────────────────
// Refs (bgUrl/overlayOpacity/bgImageOpacity) sao criados pelo
// composable; persistencia continua aqui no pai (saveLayoutPrefs +
// applyPrefsPayload + watcher) pra agrupar com outras prefs.
const {
bgUrl,
overlayOpacity,
bgImageOpacity,
defaultBgStyle,
photoStyle,
onFileChange,
clearBg
} = useMelissaWallpaper();
// Nome do tema selecionado em "Personalizar > Temas" (Freud/Klein/Jung).
// E o ID persistido — wallpaper e' resolvido a partir dele no boot
// (sem guardar a data URL gigante no DB). null = wallpaper custom ou padrao.
const themeName = ref(null);
function setThemeName(name) {
if (name && !MELISSA_THEME_NAMES.has(name)) name = null;
themeName.value = name || null;
}
// Quando ON, os textos do hero (relogio, saudacao, resumo) ganham um
// fundo solido translucido + borda + padding. Util pra wallpapers
// com pouca transparencia onde o text-shadow nao da legibilidade.
const textBgEnabled = ref(false);
function setTextBgEnabled(v) { textBgEnabled.value = !!v; }
// ───────────────────────────────────────────────────────────────
// 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 });
}
// Surface = base de fundo das superfícies (cards, dialog body, etc).
// Usa a mesma infra de tema do app — applyThemeEngine + persist em user_settings.
const SURFACES = THEME_SURFACES;
const activeSurface = computed(() => layoutConfig.surface || (isDarkTheme.value ? 'zinc' : 'slate'));
function setSurface(name) {
if (!name || layoutConfig.surface === name) return;
layoutConfig.surface = name;
applyThemeEngine(layoutConfig);
userSettings.queuePatch({ surface_color: name });
}
// Preset = engine de tokens do PrimeVue. Mantemos so Lara e Nora
// (Aura ficou de fora — decisao de design pra reduzir ruido visual).
const PRESETS = THEME_PRESETS.filter((p) => p === 'Lara' || p === 'Nora');
function setPreset(name) {
if (!name || layoutConfig.preset === name) return;
layoutConfig.preset = name;
applyThemeEngine(layoutConfig);
userSettings.queuePatch({ preset: name });
}
// ───────────────────────────────────────────────────────────────
// Settings popover (canto superior direito)
// ───────────────────────────────────────────────────────────────
const settingsOpen = ref(false);
// ───────────────────────────────────────────────────────────────
// Timeline horizontal — range/eco/posicoes/auto-scroll/cursor "Agora"
// ───────────────────────────────────────────────────────────────
// Tudo (HORA_INICIO/FIM, hoursRange, eventosVisiveis, isFolga, todayFeriado,
// tlHScrollEl, tlEcoState, etc.) migrou pro componente MelissaTimelineHoje.
// Pai so passa eventos brutos + workRules/settings/feriados via props,
// e recebe @evento (pra abrir dialog) + @clear-filter (pra limpar tipo).
// 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 tenantStore = useTenantStore();
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:
// - M.refetch() alimenta a Agenda (FullCalendar + ocorrências virtuais)
// - refetchEventosHoje() alimenta a timeline do resumo (composable
// separado em useMelissaEventosHoje); sem isso o card visualmente
// não muda apesar do DB estar atualizado.
M.refetch();
refetchEventosHoje();
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'); }
async function onWhatsapp() {
const ev = eventoSelecionado.value;
if (!ev?.patient_id) {
toast.add({ severity: 'warn', summary: 'Paciente sem id', life: 2200 });
return;
}
const patientId = String(ev.patient_id);
fecharEvento();
// openForPatient é async — busca thread existente ou cria stub.
// Se paciente não tem telefone (ou outro erro), o store seta `error`
// e mantém `isOpen=false` silenciosamente. Aguardamos pra dar feedback.
await conversationDrawerStore.openForPatient(patientId);
if (!conversationDrawerStore.isOpen) {
const detail = conversationDrawerStore.error?.message || 'Não foi possível abrir a conversa.';
toast.add({ severity: 'warn', summary: 'WhatsApp', detail, life: 3500 });
conversationDrawerStore.error = null;
}
}
// ── Pending agenda actions ────────────────────────────────────
// Eventos clicados no resumo (timeline) podem disparar actions que
// dependem do MelissaAgenda estar montado (openProntuario, setView).
// Como o resumo NÃO tem a Agenda montada, abrimos a seção e enfileiramos
// a action — quando `melissaAgendaRef` aparece, o watch dispara.
const _pendingAgendaAction = ref(null);
watch(melissaAgendaRef, (refValue) => {
if (!refValue || !_pendingAgendaAction.value) return;
const action = _pendingAgendaAction.value;
_pendingAgendaAction.value = null;
try { action(refValue); } catch (e) { /* eslint-disable-next-line no-console */ console.warn('[pending-agenda]', e); }
});
function _callOnAgenda(action) {
if (melissaAgendaRef.value) {
try { action(melissaAgendaRef.value); } catch (e) { /* eslint-disable-next-line no-console */ console.warn('[agenda-action]', e); }
return;
}
_pendingAgendaAction.value = action;
if (secaoAberta.value !== 'agenda') abrirSecao('agenda');
}
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;
}
fecharEvento();
_callOnAgenda((agenda) => agenda.openProntuario?.(p));
}
function onHistoricoSessoes() {
const ev = eventoSelecionado.value;
if (!ev?.patient_id) {
toast.add({ severity: 'warn', summary: 'Paciente não vinculado', life: 2200 });
return;
}
const patientId = ev.patient_id;
fecharEvento();
// Abre a Agenda (se não estiver) e dispara o overlay "Todas as sessões"
// filtrado pelo paciente — mesmo fluxo do botão Sessões em .ma-dock-actions.
_callOnAgenda((agenda) => agenda.openSessoesPaciente?.(patientId));
}
// Editar cadastro do paciente vinculado à sessão. Difere de onEditEvento
// (que abre o AgendaEventDialog pra mexer na sessão em si). Reusa o
// PatientCadastroDialog já montado dentro do MelissaAgenda via método
// exposto openEditPatient.
function onEditPaciente() {
const ev = eventoSelecionado.value;
if (!ev?.patient_id) {
toast.add({ severity: 'warn', summary: 'Paciente não vinculado', life: 2200 });
return;
}
const patientId = ev.patient_id;
fecharEvento();
_callOnAgenda((agenda) => agenda.openEditPatient?.(patientId));
}
async function onEditEvento() {
// Abre AgendaEventDialog completo via composable. Eventos vindos do
// resumo (useMelissaEventosHoje) não têm `_raw` — buscamos o row
// completo agora pra alimentar o dialog (campos de recorrência,
// financeiro, terapeuta_id, etc.).
const ev = eventoSelecionado.value;
if (!ev?.id || eventoBusy.value) return;
// Se já tiver _raw (caso futuro: vier da Agenda), usa direto.
if (ev._raw) {
const raw = ev._raw;
fecharEvento();
M.onEditEvento(raw);
return;
}
eventoBusy.value = true;
try {
const { data, error } = await supabase
.from('agenda_eventos')
.select('*')
.eq('id', ev.id)
.maybeSingle();
if (error) throw error;
if (!data) {
toast.add({ severity: 'warn', summary: 'Evento não encontrado', life: 2200 });
return;
}
fecharEvento();
M.onEditEvento(data);
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha ao abrir editor', detail: e?.message || 'Erro inesperado', life: 4000 });
} finally {
eventoBusy.value = false;
}
}
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 no resumo do hero, lido pela
// timeline e pelo hero pra destacar chip ativo). Permanece aqui no
// pai porque eh state compartilhado entre dois filhos.
const filtroTipo = ref(null);
function toggleFiltro(tipo) {
filtroTipo.value = filtroTipo.value === tipo ? null : tipo;
}
function limparFiltro() {
filtroTipo.value = null;
}
// Helper de formatacao de hora — usado pelo card "proximo paciente"
// abaixo. A timeline tem sua propria copia interna.
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')}`;
}
// ───────────────────────────────────────────────────────────────
// Cards do resumo — proximo-paciente derivado de eventosHojeReais.
// ───────────────────────────────────────────────────────────────
function _iniciaisDe(nome) {
const partes = String(nome || '').trim().split(/\s+/).filter(Boolean);
if (!partes.length) return '?';
if (partes.length === 1) return partes[0].slice(0, 2).toUpperCase();
return (partes[0][0] + partes[partes.length - 1][0]).toUpperCase();
}
function _fmtTempoAte(min) {
if (min < 1) return 'agora';
if (min < 60) return `em ${min} min`;
const h = Math.floor(min / 60);
const m = min % 60;
if (m === 0) return `em ${h}h`;
return `em ${h}h${String(m).padStart(2, '0')}`;
}
const proximoPaciente = computed(() => {
const d = now.value;
const hNow = d.getHours() + d.getMinutes() / 60;
const futuros = (eventosHojeReais.value || [])
.filter((ev) => {
// Só atendimentos (tipo sessao). Bloqueios e outros não contam.
if (ev.tipo && ev.tipo !== 'sessao') return false;
const s = String(ev.status || '').toLowerCase();
if (s === 'cancelado' || s === 'cancelada' || s === 'faltou' || s === 'realizado' || s === 'realizada') return false;
// Inclui em-curso (endH > hNow) — paciente atual conta como "próximo".
return ev.endH > hNow;
})
.sort((a, b) => a.startH - b.startH);
const ev = futuros[0];
if (!ev) return null;
const emMin = Math.max(0, Math.round((ev.startH - hNow) * 60));
const emCurso = hNow >= ev.startH && hNow < ev.endH;
const nome = ev.pacienteNome || ev.label || ev.titulo || '—';
return {
ev,
nome,
iniciais: _iniciaisDe(nome),
horario: fmtHora(ev.startH),
emMin,
emCurso,
statusLabel: emCurso ? 'em curso' : _fmtTempoAte(emMin)
};
});
// WhatsApp: total de unread + preview do thread mais recente.
// Lê de `conversation_threads` (mesma view do drawer/kanban).
const { summary: whatsappPendente } = useMelissaWhatsapp();
// Notificações + ajuda + plan switcher (DEV) — triggers do "topbar" Melissa.
const notificationStore = useNotificationStore();
useNotifications(); // hook side-effect: realtime + toasts de system_alerts
const { openDrawer: openAjudaDrawer, closeDrawer: closeAjudaDrawer, drawerOpen: ajudaDrawerOpen } = useAjuda();
function toggleAjuda() {
ajudaDrawerOpen.value ? closeAjudaDrawer() : openAjudaDrawer();
}
const {
planBtn,
planMenu,
planMenuModel,
planMenuLoading,
trocandoPlano,
showPlanDevMenu,
openPlanMenu
} = useTopbarPlanMenu();
// Recebíveis derivados de agenda_eventos.{price,billed}: aproximação MVP.
// `billed=true` é o flag de "marcado como pago/cobrado" no agenda — não
// é a fonte de verdade financeira (essa é financial_records.status='paid'),
// mas é coerente com o resto da timeline e suficiente pro card de resumo.
// Refinar depois se quiser fidelidade real.
const recebiveis = computed(() => {
const eventos = (eventosHojeReais.value || []).filter((ev) => {
if (ev.tipo && ev.tipo !== 'sessao') return false;
const s = String(ev.status || '').toLowerCase();
if (s === 'cancelado' || s === 'cancelada' || s === 'faltou') return false;
return true;
});
let previsto = 0;
let recebido = 0;
let sessoesPagas = 0;
for (const ev of eventos) {
const p = Number(ev.price || 0);
previsto += p;
if (ev.billed) {
recebido += p;
sessoesPagas += 1;
}
}
return {
previsto,
recebido,
sessoes: eventos.length,
sessoesPagas,
hasPrice: previsto > 0
};
});
const recebidoPct = computed(() => {
const r = recebiveis.value;
return r.previsto > 0 ? Math.round((r.recebido / r.previsto) * 100) : 0;
});
// Copilot: backend real (LLM/insights) ainda não existe. Mantido no
// catálogo como `implementado: false` pra renderizar via branch placeholder
// "Em breve" — sem mock que finja funcionalidade.
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
// Preferencia de toque + funcao de teste (composable). cronoRef/abrir/fechar
// continuam aqui — sao instance state do <MelissaCronometro>, nao da pref.
const { toqueTermino, testarToque } = useMelissaToques('sino');
function abrirCronometro() {
cronoRef.value?.abrir();
}
function fecharCronometro() {
cronoRef.value?.fechar();
}
// ── Cronometro: salvar tempo na sessao agendada ──────────────
// Quando o user para o cronometro com paciente selecionado, busca a
// sessao agendada do dia desse paciente em agenda_eventos e grava o
// tempo cronometrado em extra_fields.cronometro_*. Se nao encontrar
// sessao agendada hoje, avisa e nao falha silencioso.
async function onCronometroSessionEnd({ pacienteId, elapsedSec, stoppedAt }) {
if (!pacienteId || !Number.isFinite(elapsedSec) || elapsedSec <= 0) return;
const tenantId = tenantStore?.activeTenantId || tenantStore?.tenantId;
if (!tenantId) {
toast.add({ severity: 'warn', summary: 'Sem tenant ativo', detail: 'Não foi possível salvar o tempo.', life: 3500 });
return;
}
// Janela do dia em ISO (00:00 ate 23:59:59 local).
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0).toISOString();
const tomorrowStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0).toISOString();
try {
const { data: sessao, error } = await supabase
.from('agenda_eventos')
.select('id, extra_fields, inicio_em')
.eq('tenant_id', tenantId)
.eq('patient_id', pacienteId)
.eq('tipo', 'sessao')
.gte('inicio_em', todayStart)
.lt('inicio_em', tomorrowStart)
.order('inicio_em', { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw error;
if (!sessao) {
toast.add({
severity: 'warn',
summary: 'Sessão não encontrada',
detail: 'Não há sessão agendada hoje pra este paciente — tempo não foi salvo.',
life: 4500
});
return;
}
const newExtra = {
...(sessao.extra_fields && typeof sessao.extra_fields === 'object' ? sessao.extra_fields : {}),
cronometro_duracao_seg: Math.round(elapsedSec),
cronometro_parado_em: stoppedAt
};
const { error: upErr } = await supabase
.from('agenda_eventos')
.update({ extra_fields: newExtra })
.eq('id', sessao.id);
if (upErr) throw upErr;
const min = Math.round(elapsedSec / 60);
toast.add({
severity: 'success',
summary: 'Tempo registrado',
detail: `${min} min cronometrados salvos na sessão.`,
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'Falha ao salvar tempo',
detail: e?.message || 'Tente novamente.',
life: 4500
});
}
}
// Provide das prefs/refs pro MelissaConfiguracoes (página interna de
// configs). Posicionado aqui pra que TODAS as refs/funções referenciadas
// já estejam definidas no momento do setup. A página lê/escreve direto
// nas refs — watchers de persistência (localStorage + DB) ficam neste
// arquivo, mais abaixo.
provide('melissaSettings', {
layoutConfig,
isDarkTheme,
activeSurface,
PRIMARY_COLORS,
SURFACES,
PRESETS,
setPrimary,
setSurface,
setPreset,
setDark,
// bg + opacidades + handler do input <type="file">
bgUrl,
overlayOpacity,
bgImageOpacity,
onFileChange,
clearBg,
// relógio
use24h,
// cronômetro
toqueTermino,
testarToque,
// tema (bundle wallpaper + cores) — Freud/Klein/Jung
themeName,
setThemeName,
// fundo nos textos do hero (relogio, saudacao, resumo)
textBgEnabled,
setTextBgEnabled
});
// ───────────────────────────────────────────────────────────────
// 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;
}
if (typeof prefs.themeName === 'string' && MELISSA_THEME_NAMES.has(prefs.themeName)) {
themeName.value = prefs.themeName;
} else if (prefs.themeName === null) {
themeName.value = null;
}
if (typeof prefs.textBgEnabled === 'boolean') {
textBgEnabled.value = prefs.textBgEnabled;
}
}
function currentPrefsSnapshot() {
return {
toqueTermino: toqueTermino.value,
overlayOpacity: overlayOpacity.value,
bgImageOpacity: bgImageOpacity.value,
use24h: use24h.value,
cardsAtivos: cardsAtivos.value,
cardsLayout: cardsLayout.value,
themeName: themeName.value,
textBgEnabled: textBgEnabled.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
}
}
// Quando themeName carregou mas bgUrl ainda esta vazio (ex: 1a vez em outro
// device), resolve a imagem do tema e gera a data URL. Idempotente: se ja
// tem bgUrl, nao mexe (custom upload ou data URL ja restaurada do storage).
async function resolveThemeWallpaperIfNeeded() {
if (!themeName.value) return;
if (bgUrl.value) return;
const t = findMelissaTheme(themeName.value);
if (!t) return;
try {
const res = await fetch(t.image);
const blob = await res.blob();
const dataUrl = await new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(r.result);
r.onerror = reject;
r.readAsDataURL(blob);
});
bgUrl.value = dataUrl;
} catch {
bgUrl.value = t.image; // fallback: URL direta funciona na sessao
}
}
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, themeName, textBgEnabled],
() => {
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
// Se o user logou em outro device, themeName vem do DB mas bgUrl
// (data URL) nao — resolvemos a imagem do tema agora.
await resolveThemeWallpaperIfNeeded();
});
// Auto-scroll inicial + ResizeObserver da timeline migrou pro
// componente MelissaTimelineHoje (agora self-contained).
// ───────────────────────────────────────────────────────────────
// 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;
// Bail-out: se há um overlay PrimeVue (Dialog/Drawer) aberto, deixa
// o componente cuidar do ESC pelo seu próprio closeOnEscape. Sem
// este guard, o ESC fechava o overlay E uma camada do cascade —
// o usuário via "duas janelas fechando" (drawer WhatsApp + agenda,
// AgendaEventDialog + evento panel, ConfirmDialog + workspace, etc).
if (document.querySelector('.p-dialog-mask, .p-drawer-mask')) return;
// Cascata top-down do z-order — ESC fecha SOMENTE a camada do topo.
// Ordem (mais sobreposto → menos): central modal > evento panel >
// cronômetro > seção (agenda/pacientes/etc) > workspace > settings.
if (centralOpen.value) centralOpen.value = false;
else if (eventoSelecionado.value) fecharEvento();
else if (cronoVisible.value) fecharCronometro();
else if (secaoAberta.value) fecharSecao();
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 }">
<!-- Faixa de fundo do topbar gradiente horizontal
(cor solida na direita -> transparente na esquerda)
pra dar legibilidade aos icones sem virar barra solida.
Cor flipa com light/dark via --m-band. -->
<div class="melissa-topbar-band" aria-hidden="true"></div>
<!-- Topbar Melissa (canto sup. direito): plan-DEV + notificações
+ ajuda + cog. Os 3 primeiros vêm do AppTopbar replicados
aqui porque a rota /melissa é fullscreen e não monta o
AppLayout. Drawer de notificações também é montado abaixo
(AjudaDrawer é global no App.vue). -->
<div class="absolute top-5 right-5 z-30 flex items-center gap-2">
<!-- Plan switcher DEV ( aparece em dev / com flag) -->
<button
v-if="showPlanDevMenu"
ref="planBtn"
class="glass-btn w-10 h-10 grid place-items-center"
:disabled="planMenuLoading || trocandoPlano"
title="Plano (DEV)"
@click="openPlanMenu"
>
<i v-if="planMenuLoading || trocandoPlano" class="pi pi-spin pi-spinner text-white/90 text-base" />
<i v-else class="pi pi-sliders-h text-white/90 text-base" />
</button>
<!-- Notificações -->
<button
class="glass-btn w-10 h-10 grid place-items-center relative"
title="Notificações"
@click="notificationStore.drawerOpen = true"
>
<i class="pi pi-bell text-white/90 text-base" />
<span
v-if="notificationStore.unreadCount > 0"
class="m-topbar-badge"
>{{ notificationStore.unreadCount > 99 ? '99+' : notificationStore.unreadCount }}</span>
</button>
<!-- Ajuda -->
<button
class="glass-btn w-10 h-10 grid place-items-center"
:class="{ 'glass-btn--active': ajudaDrawerOpen }"
:title="ajudaDrawerOpen ? 'Fechar ajuda' : 'Ajuda'"
@click="toggleAjuda"
>
<i class="pi pi-question-circle text-white/90 text-base" />
</button>
<!-- Cog (settings popover) existente -->
<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">
<MelissaSettingsPanel
v-if="settingsOpen"
@close="settingsOpen = false"
/>
</Transition>
</div>
<!-- Conteúdo central -->
<div class="win11-summary__inner">
<!-- Bloco hero: relógio + data + saudação + resumo do dia -->
<MelissaHeroClock
:hora="horaFormatada"
:data-extenso="dataExtenso"
:saudacao="saudacao"
:resumo-partes="resumoPartes"
:filtro-tipo="filtroTipo"
:text-bg="textBgEnabled"
@cronometro="abrirCronometro"
@toggle-filtro="toggleFiltro"
/>
<!-- Busca rápida (entre o "Hoje há" e a timeline) -->
<MelissaBusca
class="mt-8"
:pacientes="pacientesReais"
:eventos="eventosHojeReais"
@acao="abrirSecao"
@paciente="() => abrirSecao('pacientes')"
@evento="abrirEvento"
/>
<!-- Timeline horizontal + vertical (responsivo) -->
<MelissaTimelineHoje
:eventos="eventosHojeReais"
:now="now"
:work-rules="agendaWorkRules"
:agenda-settings="agendaSettings"
:feriados="agendaFeriados"
:filtro-tipo="filtroTipo"
@evento="abrirEvento"
@clear-filter="limparFiltro"
/>
<!-- 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="proximoPaciente ? 'Abrir sessão' : 'Abrir Pacientes'"
@open="proximoPaciente ? abrirEvento(proximoPaciente.ev) : abrirSecao('pacientes')"
>
<div v-if="proximoPaciente" class="flex items-center gap-3">
<div
class="w-12 h-12 rounded-full grid place-items-center text-white font-semibold border"
:class="proximoPaciente.emCurso
? 'bg-emerald-500/40 border-emerald-300/60 ring-2 ring-emerald-400/40 ring-offset-0 animate-pulse'
: 'bg-emerald-500/30 border-emerald-300/40'"
>
{{ 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 }} · {{ proximoPaciente.statusLabel }}
</div>
</div>
</div>
<div v-else class="flex items-center gap-3">
<div class="w-12 h-12 rounded-full bg-white/5 border border-white/10 grid place-items-center text-white/40">
<i class="pi pi-check text-base" />
</div>
<div class="min-w-0">
<div class="text-white/75 text-sm font-medium">Sem atendimentos pendentes</div>
<div class="text-white/45 text-xs">Tudo certo por hoje</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 || null"
action-title="Abrir WhatsApp"
@open="abrirSecao('conversas')"
>
<template v-if="whatsappPendente.count > 0">
<div class="text-white/85 text-xs italic line-clamp-2 leading-relaxed">
{{ whatsappPendente.ultimaMsg || '(mensagem sem texto)' }}
</div>
<div class="text-white/50 text-[0.7rem] mt-1"> {{ whatsappPendente.ultimoNome }}</div>
</template>
<template v-else>
<div class="text-white/75 text-sm font-medium">Caixa de entrada limpa</div>
<div class="text-white/45 text-[0.7rem] mt-1">Sem mensagens não lidas.</div>
</template>
</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')"
>
<template v-if="recebiveis.hasPrice">
<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>
</template>
<template v-else-if="recebiveis.sessoes > 0">
<div class="text-white/75 text-sm font-medium">
{{ recebiveis.sessoes }} {{ recebiveis.sessoes === 1 ? 'sessão' : 'sessões' }} sem valor
</div>
<div class="text-white/45 text-[0.7rem] mt-1">
Defina o preço nas sessões pra acompanhar.
</div>
</template>
<template v-else>
<div class="text-white/75 text-sm font-medium">Sem cobranças hoje</div>
<div class="text-white/45 text-[0.7rem] mt-1">Nenhuma sessão agendada.</div>
</template>
</MelissaCard>
<!-- Card "em breve" placeholder pra ids do catálogo ainda não
implementados (inclui Copilot enquanto não backend de IA) -->
<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 -->
<!-- Veu de blur: 1 layer unica com backdrop-filter por cima do resumo,
em vez de aplicar filter:blur na arvore inteira do .win11-summary
(5+ glass-panels com backdrop-filter proprio = layer explosion).
Custo: 1 backdrop pass por frame, contra N×N. opacity-only
transition + contain:strict isola repaints. -->
<div class="win11-blur-veil" :class="{ 'is-active': summaryDimmed }" aria-hidden="true" />
<!-- 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) + atalhos -->
<!-- pinned (Agenda, WhatsApp). Transparent, os items são -->
<!-- clicáveis. ψ vive ao lado (absolute, bottom-left). -->
<!-- -->
<div class="melissa-dock">
<!-- Pinned: atalhos diretos pras seções mais usadas.
Tamanho menor que ψ (44px vs 56px) + cantos arredondados
mas não full-circle, pra hierarquia visual ficar óbvia. -->
<button
type="button"
class="dock-pin"
v-tooltip.top="'Agenda'"
:class="{ 'dock-pin--active': secaoAberta === 'agenda' }"
@click="abrirSecao('agenda')"
>
<i class="pi pi-calendar" />
</button>
<button
type="button"
class="dock-pin"
v-tooltip.top="'WhatsApp'"
:class="{ 'dock-pin--active': secaoAberta === 'conversas' }"
@click="abrirSecao('conversas')"
>
<i class="pi pi-whatsapp" />
<span
v-if="whatsappPendente.count > 0"
class="dock-pin__badge"
:title="`${whatsappPendente.count} mensagens não lidas`"
>{{ whatsappPendente.count > 99 ? '99+' : whatsappPendente.count }}</span>
</button>
<!-- Divisor entre builtins e pins dinâmicos. aparece se
o user tem pelo menos 1 pin (fixo ou recente). -->
<div
v-if="dockPins.pinned.value.length || dockPins.recent.value.length"
class="dock-divider"
aria-hidden="true"
/>
<!-- Pins fixados pelo user (max 4). Click direito menu
desafixar/remover. Hover mostra subtle ring. -->
<button
v-for="slug in dockPins.pinned.value" :key="`p-${slug}`"
type="button"
class="dock-pin dock-pin--user"
:class="{ 'dock-pin--active': secaoAberta === slug }"
v-tooltip.top="pinMeta(slug).label + ' (fixado)'"
@click="abrirSecao(slug)"
@contextmenu="openPinContextMenu($event, slug)"
>
<i :class="pinMeta(slug).icon" />
<span class="dock-pin__pinned-mark" aria-hidden="true" />
</button>
<!-- Pins MRU (max 3) empurrados pelas últimas seções abertas.
Visual mais leve (opacity menor) pra destacar dos fixos. -->
<button
v-for="slug in dockPins.recent.value" :key="`r-${slug}`"
type="button"
class="dock-pin dock-pin--recent"
:class="{ 'dock-pin--active': secaoAberta === slug }"
v-tooltip.top="pinMeta(slug).label + ' (recente clique direito pra fixar)'"
@click="abrirSecao(slug)"
@contextmenu="openPinContextMenu($event, slug)"
>
<i :class="pinMeta(slug).icon" />
</button>
<!-- Menu de contexto dos pins dinâmicos (popup global) -->
<Menu ref="pinContextMenu" :model="pinContextMenuItems" :popup="true" />
</div>
<!-- -->
<!-- MENU FLOAT (Win11-style Start) abre via ψ ou Ctrl+\ -->
<!-- -->
<Transition name="menu-rise">
<MelissaMenu
v-if="workspaceOpen"
:secao-ativa="secaoAberta"
@select="abrirSecao"
@close="closeWorkspace"
@open-settings="onMenuOpenSettings"
/>
</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-sessao="onEditEvento"
@edit-paciente="onEditPaciente"
@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) -->
<!-- -->
<!-- Sem <Transition> wrapper aqui: o leave delay criava janela onde
os <Teleport> internos (.melissa-dock, #ma-mobile-drawer-target)
ficavam com placeholders órfãos no target compartilhado, e o
próximo patch lia .component anulado "Cannot set properties of
null (setting '__vnode')". A animação de entrada vive como
keyframe em .ma-page / .mp-page nos próprios componentes. -->
<MelissaAgenda
v-if="layoutReady && secaoAberta === 'agenda'"
ref="melissaAgendaRef"
:pacientes="pacientesReais"
:pacientes-loading="pacientesLoading"
@select-evento="abrirEvento"
@close="fecharSecao"
@patient-created="refetchPacientes"
/>
<MelissaPacientes
v-if="layoutReady && secaoAberta === 'pacientes'"
@close="fecharSecao"
@patient-created="refetchPacientes"
@goto-agenda="abrirSecao('agenda')"
@goto-grupos="abrirSecao('grupos')"
@goto-tags="abrirSecao('tags')"
/>
<MelissaCompromissos
v-if="layoutReady && secaoAberta === 'compromissos'"
@close="fecharSecao"
/>
<MelissaRecorrencias
v-if="layoutReady && secaoAberta === 'recorrencias'"
@close="fecharSecao"
/>
<MelissaConversas
v-if="layoutReady && secaoAberta === 'conversas'"
@close="fecharSecao"
/>
<MelissaTags
v-if="layoutReady && secaoAberta === 'tags'"
@close="fecharSecao"
/>
<MelissaGrupos
v-if="layoutReady && secaoAberta === 'grupos'"
@close="fecharSecao"
/>
<MelissaCadastrosRecebidos
v-if="layoutReady && secaoAberta === 'cadastros-recebidos'"
@close="fecharSecao"
/>
<MelissaAgendamentosRecebidos
v-if="layoutReady && secaoAberta === 'agendamentos-recebidos'"
@close="fecharSecao"
/>
<MelissaMedicos
v-if="layoutReady && secaoAberta === 'medicos'"
@close="fecharSecao"
/>
<MelissaLinkExterno
v-if="layoutReady && secaoAberta === 'link-externo'"
@close="fecharSecao"
/>
<MelissaNotificacoes
v-if="layoutReady && secaoAberta === 'notificacoes'"
@close="fecharSecao"
/>
<MelissaFinanceiro
v-if="layoutReady && secaoAberta === 'financeiro'"
@close="fecharSecao"
/>
<MelissaFinanceiroLancamentos
v-if="layoutReady && secaoAberta === 'financeiro-lancamentos'"
@close="fecharSecao"
/>
<MelissaDocumentos
v-if="layoutReady && secaoAberta === 'documentos'"
@close="fecharSecao"
/>
<MelissaDocumentosTemplates
v-if="layoutReady && secaoAberta === 'documentos-templates'"
@close="fecharSecao"
/>
<MelissaRelatorios
v-if="layoutReady && secaoAberta === 'relatorios'"
@close="fecharSecao"
/>
<MelissaPerfil
v-if="layoutReady && secaoAberta === 'perfil'"
@close="fecharSecao"
/>
<MelissaPlano
v-if="layoutReady && secaoAberta === 'plano'"
@close="fecharSecao"
/>
<MelissaNegocio
v-if="layoutReady && secaoAberta === 'negocio'"
@close="fecharSecao"
/>
<MelissaAlterarPlano
v-if="layoutReady && secaoAberta === 'alterar-plano'"
@close="fecharSecao"
@goto="abrirSecao"
/>
<MelissaSeguranca
v-if="layoutReady && secaoAberta === 'seguranca'"
@close="fecharSecao"
/>
<MelissaBloqueios
v-if="layoutReady && secaoAberta === 'bloqueios'"
@close="fecharSecao"
/>
<MelissaAgendador
v-if="layoutReady && secaoAberta === 'online-scheduling'"
@close="fecharSecao"
/>
<MelissaAgendaConfig
v-if="layoutReady && secaoAberta === 'agenda-config'"
@close="fecharSecao"
/>
<MelissaPagamento
v-if="layoutReady && secaoAberta === 'pagamento'"
@close="fecharSecao"
/>
<MelissaPrecificacao
v-if="layoutReady && secaoAberta === 'cfg-precificacao'"
@close="fecharSecao"
/>
<MelissaDescontos
v-if="layoutReady && secaoAberta === 'cfg-descontos'"
@close="fecharSecao"
/>
<MelissaExcecoes
v-if="layoutReady && secaoAberta === 'cfg-excecoes'"
@close="fecharSecao"
/>
<MelissaConvenios
v-if="layoutReady && secaoAberta === 'cfg-convenios'"
@close="fecharSecao"
/>
<MelissaCfgWa
v-if="layoutReady && secaoAberta === 'cfg-wa'"
@close="fecharSecao"
/>
<MelissaCfgWaPessoal
v-if="layoutReady && secaoAberta === 'cfg-wa-pessoal'"
@close="fecharSecao"
/>
<MelissaCfgWaOficial
v-if="layoutReady && secaoAberta === 'cfg-wa-oficial'"
@close="fecharSecao"
/>
<MelissaCfgWaTemplates
v-if="layoutReady && secaoAberta === 'cfg-wa-templates'"
@close="fecharSecao"
/>
<MelissaCfgConversasTags
v-if="layoutReady && secaoAberta === 'cfg-conversas-tags'"
@close="fecharSecao"
/>
<MelissaCfgConversasAutoreply
v-if="layoutReady && secaoAberta === 'cfg-conversas-autoreply'"
@close="fecharSecao"
/>
<MelissaCfgConversasOptouts
v-if="layoutReady && secaoAberta === 'cfg-conversas-optouts'"
@close="fecharSecao"
/>
<MelissaCfgConversasSla
v-if="layoutReady && secaoAberta === 'cfg-conversas-sla'"
@close="fecharSecao"
/>
<MelissaCfgConversasBots
v-if="layoutReady && secaoAberta === 'cfg-conversas-bots'"
@close="fecharSecao"
/>
<MelissaCfgLembretes
v-if="layoutReady && secaoAberta === 'cfg-lembretes'"
@close="fecharSecao"
/>
<MelissaCfgCreditosWa
v-if="layoutReady && secaoAberta === 'cfg-creditos-wa'"
@close="fecharSecao"
/>
<MelissaCfgSms
v-if="layoutReady && secaoAberta === 'cfg-sms'"
@close="fecharSecao"
/>
<MelissaCfgEmailTemplates
v-if="layoutReady && secaoAberta === 'cfg-email-templates'"
@close="fecharSecao"
/>
<MelissaCfgRecursosExtras
v-if="layoutReady && secaoAberta === 'cfg-recursos-extras'"
@close="fecharSecao"
/>
<MelissaCfgRecursosExtrasExtrato
v-if="layoutReady && secaoAberta === 'cfg-recursos-extras-extrato'"
@close="fecharSecao"
/>
<MelissaCfgAuditoria
v-if="layoutReady && secaoAberta === 'cfg-auditoria'"
@close="fecharSecao"
/>
<MelissaConfiguracoes
v-if="layoutReady && isMelissaConfigRoute(secaoAberta)"
:secao-rota="secaoAberta"
@close="fecharSecao"
/>
<MelissaEmbed
v-if="layoutReady && MELISSA_EMBED_KEYS.includes(secaoAberta)"
:secao-rota="secaoAberta"
@close="fecharSecao"
/>
<!-- -->
<!-- SEÇÃO placeholder dialog pras sessões ainda não promovidas -->
<!-- (WhatsApp, Financeiro, Copilot...) -->
<!-- -->
<Transition name="lift">
<div
v-if="secaoAberta && !MELISSA_NON_CONFIG_SLUGS.has(secaoAberta) && !isMelissaConfigRoute(secaoAberta)"
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"
@session-end="onCronometroSessionEnd"
/>
<!-- 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"
/>
<!-- Toast: AppLayout não monta no Melissa (rota fullscreen),
então as pages embedadas (config, agendador online, etc.)
precisam de um Toast próprio aqui pra não silenciar o
feedback de save. -->
<Toast />
<!-- Drawer de notificações (idem: AppTopbar não monta aqui) -->
<NotificationDrawer />
<!-- Plan menu DEV popup ancorado no botão da topbar -->
<Menu ref="planMenu" :model="planMenuModel" popup appendTo="body" :baseZIndex="3000" />
</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;
}
/* (Removido) .melissa-config-aside-host — o sidebar global foi
substituido pelo botao + MelissaConfigPopover em cada pagina de
config. CSS var --m-config-aside-left tambem nao e mais setada
aqui; as pages tem `inset: ... 6px` fixo de novo. */
/* ─── Plano de trás (resumo) ───────────────────────────────────
Animacao composicao-pura (transform + opacity) — ambos sao free
no compositor (nao re-pintam, nao re-layout). O blur deixou de
estar AQUI (era filter no container = re-render de toda a arvore
a cada frame, com layer explosion via backdrop-filter dos
glass-panels filhos). Agora vem do .win11-blur-veil (irmao
abaixo) — 1 backdrop pass unico em vez de N×N.
will-change + translateZ(0): promove pra layer dedicada ANTES
do transition disparar. Sem isso, o browser cria a layer no
primeiro frame e gera stutter visivel.
backface-visibility: evita sub-pixel jitter no scale(0.98).
*/
.win11-summary {
position: relative;
z-index: 10;
width: 100%;
height: 100%;
transition: transform 320ms ease, opacity 320ms ease;
will-change: transform, opacity;
transform: translateZ(0);
backface-visibility: hidden;
}
.win11-summary.is-behind {
transform: scale(0.98) translateZ(0);
opacity: 0.55;
pointer-events: none;
}
/* ─── Veu de blur sobre o resumo ───────────────────────────────
1 unica camada com backdrop-filter cobrindo o resumo. Substitui
o filter:blur antigo aplicado no .win11-summary inteiro. Em vez
de blurar 5+ glass-panels filhos (cada um com seu proprio
backdrop-filter), e' UM blur unico que pinta o resultado
acumulado do resumo abaixo.
z-index 11: acima do .win11-summary (10) e abaixo dos overlays
de seção/cronometro/menu (≥30). pointer-events: none deixa o
click atravessar pra o overlay aberto.
contain: strict promove layer + isola repaints do veu do resto
do .win11-root (fix de "first-frame stutter"). will-change na
opacity prepara o compositor pro transition.
Mobile: blur de 3px ainda eh caro em GPU mid-tier; reduz pra
2px no media query abaixo. */
.win11-blur-veil {
position: absolute;
inset: 0;
z-index: 11;
pointer-events: none;
opacity: 0;
transition: opacity 320ms ease;
backdrop-filter: blur(3px);
-webkit-backdrop-filter: blur(3px);
will-change: opacity;
contain: strict;
}
.win11-blur-veil.is-active {
opacity: 1;
}
@media (max-width: 768px) {
.win11-blur-veil {
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
}
.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 ──────────────────────────────────────────────── */
/* ─── 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, border-color 160ms ease, box-shadow 160ms ease;
cursor: pointer;
}
.glass-btn:hover {
background: var(--m-bg-soft-hover);
}
.glass-btn:disabled {
cursor: default;
opacity: 0.6;
}
/* Active state (drawer aberto, popover aberto, etc.) — primary tint */
.glass-btn--active {
background: color-mix(in srgb, var(--p-primary-color) 24%, transparent);
border-color: color-mix(in srgb, var(--p-primary-color) 55%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--p-primary-color) 35%, transparent);
}
/* Badge de count (notificações não lidas) — espelha .dock-pin__badge
mas em contexto de glass-btn topbar (canto sup. dir.). */
.m-topbar-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9999px;
background: rgb(239, 68, 68);
color: white;
font-size: 0.6rem;
font-weight: 700;
display: grid;
place-items: center;
line-height: 1;
border: 1.5px solid var(--m-bg-medium, rgba(0, 0, 0, 0.4));
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
pointer-events: none;
}
/* ─── 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);
}
/* ─── Botão-trigger do cronômetro (irmão flex do relógio) ───── */
/* ─── 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.) */
/* (removido em 2026-04-29: .page-fade-* não é mais usado. As páginas
fullscreen perderam o <Transition> wrapper porque o leave delay
gerava orphan placeholders nos Teleport targets compartilhados.
Animação de entrada agora vive como @keyframes nos próprios
componentes — ver .ma-page / .mp-page.) */
/* ─── 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);
}
/* Em mobile (<lg) o painel vira drawer da esquerda — anima translateX
em vez de translateY+scale. Backdrop do .mm-layer também anima opacity. */
@media (max-width: 1023px) {
.menu-rise-enter-active :deep(.mm-panel),
.menu-rise-leave-active :deep(.mm-panel) {
transition: transform 280ms cubic-bezier(0.4, 0, 0.2, 1);
}
.menu-rise-enter-from :deep(.mm-panel),
.menu-rise-leave-to :deep(.mm-panel) {
opacity: 1;
transform: translateX(-100%);
}
.menu-rise-enter-active :deep(.mm-layer),
.menu-rise-leave-active :deep(.mm-layer) {
transition: opacity 240ms ease;
}
.menu-rise-enter-from :deep(.mm-layer),
.menu-rise-leave-to :deep(.mm-layer) {
opacity: 0;
}
}
/* ─── 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;
/* ─── Faixa de fundo (top + dock) ───────────────────────────────
Cor solida com 80% de opacidade pra fazer gradiente horizontal
e dar legibilidade aos icones sem virar barra hard. Default =
dark (preto); light flipa pra branco (override em html:not(.app-dark)). */
--m-band: rgba(0, 0, 0, 0.8);
/* ─── Fundo dos textos do hero (relogio/saudacao/resumo) ─────────
Toggle "fundo nos textos soltos" no painel Personalizar. 60% de
opacidade pra dar legibilidade sobre wallpapers complexos sem
sumir totalmente o look do bg. Default = dark; light flipa. */
--m-hero-text-bg: rgba(0, 0, 0, 0.6);
--m-hero-text-border: rgba(255, 255, 255, 0.12);
}
/* ─── Faixa de fundo do topbar (canto sup. direito) ──────────────
Gradiente horizontal: cor solida na direita (onde os icones vivem)
e fade pra transparente na esquerda. z-index abaixo do topbar
(z-30) e acima do conteudo principal. */
.melissa-topbar-band {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 80px;
z-index: 25;
pointer-events: none;
background: linear-gradient(
to left,
var(--m-band) 0%,
var(--m-band) 25%,
transparent 75%
);
}
/* ─── Dock (global pra atravessar Teleport + evitar perda de scoped
em static-hoisted nodes). Faixa horizontal no bottom com gradiente
espelhado: cor solida na esquerda (onde os pins ficam) e fade
pra transparente na direita. */
.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 */
background: linear-gradient(
to right,
var(--m-band) 0%,
var(--m-band) 35%,
transparent 85%
);
}
.melissa-dock > * { pointer-events: auto; }
/* ─── Dock pinned items (Agenda, WhatsApp) ──────────────────────
Tamanho menor que o ψ (44px vs 56px) + canto arredondado em vez
de full-circle: hierarquia visual auto-resolvida. ψ = "tudo",
pinned = atalhos. Hover lifta + ring. Active state mostra qual
seção tá aberta. */
.dock-pin {
position: relative;
width: 44px;
height: 44px;
display: grid;
place-items: center;
border-radius: 12px;
background: var(--m-bg-soft, rgba(255, 255, 255, 0.06));
backdrop-filter: blur(20px) saturate(160%);
-webkit-backdrop-filter: blur(20px) saturate(160%);
border: 1px solid var(--m-border, rgba(255, 255, 255, 0.10));
color: var(--m-text, white);
cursor: pointer;
font-family: inherit;
transition: transform 180ms ease, background-color 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
}
.dock-pin > i {
font-size: 1.05rem;
opacity: 0.92;
}
.dock-pin:hover {
transform: translateY(-3px);
background: var(--m-bg-soft-hover, rgba(255, 255, 255, 0.10));
border-color: var(--m-border-strong, rgba(255, 255, 255, 0.18));
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.28);
}
.dock-pin:active {
transform: translateY(-1px);
}
.dock-pin--active {
background: color-mix(in srgb, var(--p-primary-color) 22%, transparent);
border-color: color-mix(in srgb, var(--p-primary-color) 55%, transparent);
box-shadow:
0 6px 18px rgba(0, 0, 0, 0.25),
0 0 0 1px color-mix(in srgb, var(--p-primary-color) 40%, transparent);
}
.dock-pin--active > i { opacity: 1; }
/* Badge de count (WhatsApp não-lidas) */
.dock-pin__badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9999px;
background: rgb(239, 68, 68);
color: white;
font-size: 0.6rem;
font-weight: 700;
display: grid;
place-items: center;
line-height: 1;
border: 1.5px solid var(--m-bg-medium, rgba(0, 0, 0, 0.4));
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
pointer-events: none;
}
/* Light mode: dock-pin precisa de mais contraste sobre bloom claro */
html:not(.app-dark) .melissa-dock .dock-pin {
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.10);
}
html:not(.app-dark) .melissa-dock .dock-pin:hover {
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.16);
}
/* ─── Pins dinâmicos do dock (híbrido fixo + recente) ───────── */
/* Divisor entre builtins e dinâmicos. Fininho, atravessa o gap. */
.dock-divider {
width: 1px;
height: 24px;
align-self: center;
background: var(--m-border, rgba(255, 255, 255, 0.18));
opacity: 0.7;
}
html:not(.app-dark) .dock-divider {
background: rgba(15, 23, 42, 0.18);
}
/* Pin fixado pelo user — pequena marca no canto pra diferenciar do recente. */
.dock-pin--user .dock-pin__pinned-mark {
position: absolute;
top: 4px;
right: 4px;
width: 6px;
height: 6px;
border-radius: 50%;
background: color-mix(in srgb, var(--p-primary-color) 70%, white);
box-shadow: 0 0 6px color-mix(in srgb, var(--p-primary-color) 50%, transparent);
pointer-events: none;
}
/* Pin recente (MRU) — visualmente mais leve pra denotar transitoriedade.
Fica entre opacity total (active/hover) e ~70% no estado normal. */
.dock-pin--recent {
opacity: 0.78;
}
.dock-pin--recent:hover,
.dock-pin--recent.dock-pin--active {
opacity: 1;
}
/* ─── Skeleton loading utilitário (global pra atravessar scoped CSS dos
componentes filhos). Use a classe .melissa-skeleton em qualquer
container — vira um placeholder com shimmer suave.
Variantes via classes adicionais:
.melissa-skeleton--text altura ~12px (linha de texto)
.melissa-skeleton--title altura ~18px (heading)
.melissa-skeleton--avatar redondo 32px
.melissa-skeleton--block altura definida via inline style
Shimmer respeita prefers-reduced-motion. */
.melissa-skeleton {
background: linear-gradient(
90deg,
var(--m-bg-soft, rgba(255, 255, 255, 0.04)) 0%,
var(--m-bg-soft-hover, rgba(255, 255, 255, 0.08)) 50%,
var(--m-bg-soft, rgba(255, 255, 255, 0.04)) 100%
);
background-size: 200% 100%;
animation: melissa-shimmer 1400ms linear infinite;
border-radius: 6px;
flex-shrink: 0;
pointer-events: none;
}
.melissa-skeleton--text { height: 12px; }
.melissa-skeleton--title { height: 18px; }
.melissa-skeleton--number { height: 24px; width: 32px; }
.melissa-skeleton--avatar { width: 32px; height: 32px; border-radius: 50%; }
@keyframes melissa-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (prefers-reduced-motion: reduce) {
.melissa-skeleton { animation: none; }
}
/* ════════════════════════════════════════════════════════════════
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);
/* Faixa de fundo do topbar/dock em light: branco 80% (vs preto 80% em dark) */
--m-band: rgba(255, 255, 255, 0.8);
/* Fundo dos textos do hero em light: branco 60% (vs preto 60% em dark) */
--m-hero-text-bg: rgba(255, 255, 255, 0.6);
--m-hero-text-border: rgba(0, 0, 0, 0.08);
}
/* 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>