Files
agenciapsilmno/src/layout/melissa/MelissaLayout.vue
T
Leonardo 269b531158 Melissa: blueprint tabular + Cadastros/Agendamentos/Pacientes + restore
Sprint E (05-05). Blueprint tabular oficial pras paginas Melissa de
listagem (DataTable + sidebar com stats e filtros coloridos, view
toggle list/grade, subheader explicativo, mobile pencil+popover).

Novo arquivo:
- blueprints/melissa-table-page-blueprint.md (~530L, 18 secoes) —
  referencia canonica MelissaCadastrosRecebidos

Paginas refatoradas/criadas:
- MelissaCadastrosRecebidos: refator pra blueprint (DataTable + frozen
  action + view toggle + subheader)
- MelissaAgendamentosRecebidos (NOVO): substitui o embed via
  MelissaEmbed; 4 status coloridos (Pendente/Autorizado/Convertido/
  Recusado), 3 acoes condicionais (Recusar/Autorizar/Converter em
  sessao), wired com AgendaEventDialog
- MelissaPacientes: refator parcial (subheader, sombras, status pills
  coloridas, email/phone colunas proprias, mobile pencil+popover, fix
  scroll mobile com min-height:0 na .mp-list, view toggle persistido,
  tags/grupos color fix g.cor->g.color, restore de arquivados)
- MelissaEmbed: agendamentos-recebidos removido do EMBED_MAP
- MelissaLayout: wire-up MelissaAgendamentosRecebidos nativo
- composables/useMelissaPacientes + useMelissaPacientesAside ajustes

Restore de pacientes arquivados:
- patientsRepository: novo restorePatient(id, { tenantId })
- PatientsCadastroPage statusOpts: +Arquivado (fecha gap de
  inconsistencia ao editar paciente arquivado)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:13:53 -03:00

3730 lines
155 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 } from '@/theme/theme.options';
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence';
import MelissaCronometro from './MelissaCronometro.vue';
import MelissaCard from './MelissaCard.vue';
import MelissaBusca from './MelissaBusca.vue';
import MelissaMenu from './MelissaMenu.vue';
import MelissaAgenda from './MelissaAgenda.vue';
import MelissaPacientes from './MelissaPacientes.vue';
import 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 MelissaEmbed from './MelissaEmbed.vue';
import MelissaCadastrosRecebidos from './MelissaCadastrosRecebidos.vue';
import MelissaAgendamentosRecebidos from './MelissaAgendamentosRecebidos.vue';
import MelissaMedicos from './MelissaMedicos.vue';
import MelissaEventoPanel from './MelissaEventoPanel.vue';
import { TOQUES, playToque } from './melissaToques';
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 { 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.' },
// Página interna de configs do layout Melissa (aparência, fundo, relógio, cronômetro)
aparencia: { label: 'Configurações do Melissa', icon: 'pi pi-cog', descricao: 'Aparência, plano de fundo, relógio e cronômetro.' },
// Atalhos de Conta — todas montam o MelissaConfiguracoes com a seção embed pré-selecionada
perfil: { label: 'Meu Perfil', icon: 'pi pi-user', descricao: 'Dados pessoais, avatar e preferências.' },
plano: { label: 'Meu Plano', icon: 'pi pi-credit-card', descricao: 'Plano contratado, limites e fatura.' },
negocio: { label: 'Meu Negócio', icon: 'pi pi-briefcase', descricao: 'Dados do negócio, faturamento e branding.' },
seguranca: { label: 'Segurança', icon: 'pi pi-shield', descricao: 'Senha, dispositivos confiáveis e sessões.' },
// 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 público de agendamento.' },
'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)
const MELISSA_EMBED_KEYS = ['financeiro', 'financeiro-lancamentos', 'documentos', 'documentos-templates', 'online-scheduling', 'relatorios', 'notificacoes', 'link-externo'];
// 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',
...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', 'perfil', 'plano', 'negocio', 'seguranca', 'bloqueios',
'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;
});
// 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: {} });
}
// ── 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';
const TOQUE_IDS = new Set(TOQUES.map((t) => t.id));
// ───────────────────────────────────────────────────────────────
// 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
// ───────────────────────────────────────────────────────────────
const bgUrl = ref(''); // vazio = usa gradiente default
const overlayOpacity = ref(0.35); // 00.8 — escurecedor sobre o bg
const bgImageOpacity = ref(1); // 0.011 — transparência da foto custom
const fileInput = ref(null);
// Limite de upload — protege quota do localStorage (~5MB) e evita data URL
// gigante atravessando a UI. JPG/PNG 1920×1080 cabe folgado nesse teto.
const MAX_BG_BYTES = 2 * 1024 * 1024; // 2 MB
function pickFile() {
fileInput.value?.click();
}
function onFileChange(e) {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.add({ severity: 'warn', summary: 'Formato inválido', detail: 'Selecione um arquivo de imagem (JPG, PNG, WEBP).', life: 4000 });
e.target.value = '';
return;
}
if (file.size > MAX_BG_BYTES) {
toast.add({
severity: 'warn',
summary: 'Imagem muito grande',
detail: 'Máximo 2 MB. Reduza a resolução ou compressão e tente novamente.',
life: 4500
});
e.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = (ev) => (bgUrl.value = ev.target.result);
reader.readAsDataURL(file);
}
function clearBg() {
bgUrl.value = '';
}
// Gradiente default — sempre renderizado no .win11-root (atrás de tudo).
// Quando o user faz upload, .win11-photo aparece por cima com opacidade
// controlada pelo slider — permite blend natural com o gradiente abaixo.
// Cores vêm de CSS vars que flipam com dark/light AND seguem o preset
// (ver style global no fim do arquivo: --bloom-c1/c2/base-1/base-2).
const defaultBgStyle = {
backgroundImage:
'radial-gradient(circle at 70% 30%, var(--bloom-c1) 0%, transparent 55%), radial-gradient(circle at 25% 75%, var(--bloom-c2) 0%, transparent 50%), linear-gradient(135deg, var(--bloom-base-1) 0%, var(--bloom-base-2) 50%, var(--bloom-base-1) 100%)',
backgroundSize: 'cover'
};
const photoStyle = computed(() => ({
backgroundImage: bgUrl.value ? `url(${bgUrl.value})` : 'none',
opacity: bgImageOpacity.value
}));
// ───────────────────────────────────────────────────────────────
// Tema (dark/light + cor primária) — usa a infra existente do app
// ───────────────────────────────────────────────────────────────
// `toggleDarkMode` flipa a classe .app-dark + layoutConfig.darkTheme.
// `applyThemeEngine` re-aplica o preset com nova primary/surface.
// `userSettings.queuePatch` persiste no DB (debounced upsert em
// user_settings — colunas theme_mode/primary_color, não toca em melissa_prefs).
const { layoutConfig, toggleDarkMode, isDarkTheme } = useLayout();
const userSettings = useUserSettingsPersistence();
onMounted(() => userSettings.init());
// Paleta enxuta — espelha primaryColors da ProfilePage. 'noir' usa
// currentColor (preto/branco conforme tema) — visualmente vira a primary
// "neutra" pro user que quer monocromático.
const PRIMARY_COLORS = [
{ name: 'noir', swatch: 'currentColor' },
{ name: 'emerald', swatch: '#10b981' },
{ name: 'green', swatch: '#22c55e' },
{ name: 'lime', swatch: '#84cc16' },
{ name: 'orange', swatch: '#f97316' },
{ name: 'amber', swatch: '#f59e0b' },
{ name: 'yellow', swatch: '#eab308' },
{ name: 'teal', swatch: '#14b8a6' },
{ name: 'cyan', swatch: '#06b6d4' },
{ name: 'sky', swatch: '#0ea5e9' },
{ name: 'blue', swatch: '#3b82f6' },
{ name: 'indigo', swatch: '#6366f1' },
{ name: 'violet', swatch: '#8b5cf6' },
{ name: 'purple', swatch: '#a855f7' },
{ name: 'fuchsia', swatch: '#d946ef' },
{ name: 'pink', swatch: '#ec4899' },
{ name: 'rose', swatch: '#f43f5e' }
];
function setDark(shouldBeDark) {
if (isDarkTheme.value === shouldBeDark) return;
toggleDarkMode();
userSettings.queuePatch({ theme_mode: shouldBeDark ? 'dark' : 'light' });
}
function setPrimary(name) {
if (!name || layoutConfig.primary === name) return;
layoutConfig.primary = name;
applyThemeEngine(layoutConfig);
userSettings.queuePatch({ primary_color: name });
}
// 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 });
}
// ───────────────────────────────────────────────────────────────
// Settings popover (canto superior direito)
// ───────────────────────────────────────────────────────────────
const settingsOpen = ref(false);
// ───────────────────────────────────────────────────────────────
// Timeline horizontal — range derivado de agenda_regras_semanais
// (regra do dia da semana atual). Fallback: agenda_configuracoes
// global → 0818h. Range expande pra incluir eventos fora do
// expediente (sessão excepcional não some da timeline).
// ───────────────────────────────────────────────────────────────
function _timeStrToHour(s, fallback) {
const str = String(s || '').slice(0, 5);
const [h, m] = str.split(':').map(Number);
if (Number.isFinite(h) && Number.isFinite(m)) return h + m / 60;
return fallback;
}
const todayRules = computed(() => {
const dow = new Date().getDay(); // 0=dom .. 6=sáb
return (agendaWorkRules.value || []).filter((r) => r.dia_semana === dow && r.ativo !== false);
});
const isFolga = computed(() => todayRules.value.length === 0);
const todayFeriado = computed(() => {
const d = new Date();
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
return (agendaFeriados.value || []).find((f) => f.data === k) || null;
});
function _baseRange() {
const rules = todayRules.value;
if (rules.length > 0) {
const starts = rules.map((r) => _timeStrToHour(r.hora_inicio, 8));
const ends = rules.map((r) => _timeStrToHour(r.hora_fim, 18));
return { start: Math.min(...starts), end: Math.max(...ends) };
}
const s = agendaSettings.value;
const fbStart = (s?.usar_horario_admin_custom && s?.admin_inicio_visualizacao) || s?.agenda_custom_start || '08:00';
const fbEnd = (s?.usar_horario_admin_custom && s?.admin_fim_visualizacao) || s?.agenda_custom_end || '18:00';
return { start: _timeStrToHour(fbStart, 8), end: _timeStrToHour(fbEnd, 18) };
}
const HORA_INICIO = computed(() => {
const { start } = _baseRange();
const eventos = eventosHojeReais.value || [];
const minEv = eventos.length ? Math.min(...eventos.map((e) => e.startH)) : Infinity;
return Math.max(0, Math.floor(Math.min(start, minEv)));
});
const HORA_FIM = computed(() => {
const { end } = _baseRange();
const eventos = eventosHojeReais.value || [];
const maxEv = eventos.length ? Math.max(...eventos.map((e) => e.endH)) : -Infinity;
return Math.min(24, Math.ceil(Math.max(end, maxEv)));
});
const hoursRange = computed(() => {
const arr = [];
for (let h = HORA_INICIO.value; h <= HORA_FIM.value; h++) arr.push(h);
return arr;
});
// Auto-scroll horizontal: ao montar, centra "agora" na viewport pra
// jornada longa (ex: 02h23h) não abrir com cursor off-screen.
// Só dispara uma vez — depois respeita o scroll manual.
// IMPORTANTE: o watch precisa rodar DEPOIS do destructure de M
// (agendaWorkRules etc.), senão TDZ explode. Daí registramos dentro
// de onMounted (mais abaixo no setup, depois das deps inicializarem).
const tlHScrollEl = ref(null);
let _tlAutoScrolled = false;
function _scrollTimelineToNow() {
const el = tlHScrollEl.value;
if (!el) return;
const d = new Date();
const h = d.getHours() + d.getMinutes() / 60;
const total = HORA_FIM.value - HORA_INICIO.value;
if (total <= 0 || h < HORA_INICIO.value || h > HORA_FIM.value) return;
const inner = el.firstElementChild;
if (!inner) return;
const innerWidth = inner.scrollWidth || inner.offsetWidth;
const visibleWidth = el.clientWidth;
if (innerWidth <= visibleWidth) return; // sem overflow, nada a rolar
const ratio = (h - HORA_INICIO.value) / total;
el.scrollLeft = Math.max(0, ratio * innerWidth - visibleWidth / 2);
}
// ── Eco lateral — tracinhos coloridos nas bordas indicando eventos
// fora da viewport. Posição vertical mapeada por tempo (mostra a
// "forma" do dia que tá off-screen). Click = scroll suave até o evento.
// Pulse sutil quando há algo não-visto. Padrão minimap consciente.
const tlScrollState = ref({ scrollL: 0, viewW: 0, innerW: 0 });
function _updateTlScrollState() {
const el = tlHScrollEl.value;
if (!el) {
tlScrollState.value = { scrollL: 0, viewW: 0, innerW: 0 };
return;
}
const inner = el.firstElementChild;
tlScrollState.value = {
scrollL: el.scrollLeft,
viewW: el.clientWidth,
innerW: inner ? (inner.scrollWidth || inner.offsetWidth) : 0
};
}
function onTimelineScroll() {
_updateTlScrollState();
}
const tlEcoState = computed(() => {
const { scrollL, viewW, innerW } = tlScrollState.value;
const total = HORA_FIM.value - HORA_INICIO.value;
const empty = { left: [], right: [], vStart: HORA_INICIO.value, vEnd: HORA_FIM.value };
if (total <= 0 || !innerW || !viewW || innerW <= viewW) return empty;
const vStart = HORA_INICIO.value + (scrollL / innerW) * total;
const vEnd = HORA_INICIO.value + ((scrollL + viewW) / innerW) * total;
const left = [];
const right = [];
for (const ev of eventosVisiveis.value) {
if (ev.endH <= vStart) left.push(ev);
else if (ev.startH >= vEnd) right.push(ev);
}
return { left, right, vStart, vEnd };
});
// Posição vertical (%) do tracinho dentro da faixa esquerda/direita.
// Esquerda: HORA_INICIO → vStart mapeado 0-100%.
// Direita: vEnd → HORA_FIM mapeado 0-100%.
function ecoTickStyle(ev, side) {
const { vStart, vEnd } = tlEcoState.value;
let topPct = 50;
if (side === 'left') {
const span = vStart - HORA_INICIO.value;
if (span > 0) topPct = ((ev.startH - HORA_INICIO.value) / span) * 100;
} else {
const span = HORA_FIM.value - vEnd;
if (span > 0) topPct = ((ev.startH - vEnd) / span) * 100;
}
return {
top: `${Math.max(0, Math.min(100, topPct))}%`,
backgroundColor: ev.color
};
}
function scrollToEvent(ev) {
const el = tlHScrollEl.value;
if (!el) return;
const inner = el.firstElementChild;
if (!inner) return;
const innerWidth = inner.scrollWidth || inner.offsetWidth;
const visibleWidth = el.clientWidth;
const total = HORA_FIM.value - HORA_INICIO.value;
if (total <= 0) return;
const ratio = (ev.startH - HORA_INICIO.value) / total;
const target = Math.max(0, Math.min(innerWidth - visibleWidth, ratio * innerWidth - visibleWidth / 2));
el.scrollTo({ left: target, behavior: 'smooth' });
}
// timelineEvents removido — agora usa `eventosHojeReais` (Supabase)
// Pra debug: ver useMelissaEventos.js em src/layout/melissa/composables/
function fmtHora(h) {
const horas = Math.floor(h);
const mins = Math.round((h - horas) * 60);
return `${String(horas).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
// Contagens por tipo + frase resumo do dia
const contagensDia = computed(() => {
const c = { sessao: 0, supervisao: 0, reuniao: 0 };
for (const ev of eventosHojeReais.value) c[ev.tipo] = (c[ev.tipo] || 0) + 1;
return c;
});
function pluralizar(n, singular, plural) {
return `${n} ${n === 1 ? singular : plural}`;
}
// Partes estruturadas pro template renderizar cada contagem como link clicável
const resumoPartes = computed(() => {
const c = contagensDia.value;
const partes = [];
if (c.sessao > 0) partes.push({ tipo: 'sessao', text: pluralizar(c.sessao, 'atendimento', 'atendimentos') });
if (c.supervisao > 0) partes.push({ tipo: 'supervisao', text: pluralizar(c.supervisao, 'supervisão', 'supervisões') });
if (c.reuniao > 0) partes.push({ tipo: 'reuniao', text: pluralizar(c.reuniao, 'reunião', 'reuniões') });
return partes;
});
// Evento selecionado (dialog de detalhes)
const eventoSelecionado = ref(null);
const eventoBusy = ref(false); // bloqueia botões enquanto UPDATE roda
const melissaAgendaRef = ref(null); // pra chamar openProntuario + setView
const toast = useToast();
const conversationDrawerStore = useConversationDrawerStore();
// ───────────────────────────────────────────────────────────────
// Agenda completa (CRUD + recorrência via AgendaEventDialog)
// ───────────────────────────────────────────────────────────────
// Composable injetado em MelissaAgenda via provide/inject — orquestra
// useAgendaEvents + useRecurrence + useDeterminedCommitments + useFeriados
// e expõe dialog state + handlers (save/delete/série/persistMoveOrResize).
// MelissaAgenda lê M.eventos pra alimentar o FullCalendar e mutará
// M.viewStart/M.viewEnd via FC.datesSet.
const M = useMelissaAgenda();
provide(MELISSA_AGENDA_KEY, M);
// Destrutura refs/computeds pro template auto-unwrappar (refs em objetos
// aninhados NÃO unwrappam no template — só os top-level do setup).
const {
dialogOpen: agendaDialogOpen,
dialogEventRow: agendaDialogEventRow,
dialogStartISO: agendaDialogStartISO,
dialogEndISO: agendaDialogEndISO,
ownerId: agendaOwnerId,
clinicTenantId: agendaClinicTenantId,
commitmentOptions: agendaCommitmentOptions,
workRules: agendaWorkRules,
settings: agendaSettings,
allEventsForDialog: agendaAllEvents,
feriados: agendaFeriados,
bloqueioDialogOpen: agendaBloqueioOpen,
bloqueioMode: agendaBloqueioMode
} = M;
function abrirEvento(ev) {
eventoSelecionado.value = ev;
}
function fecharEvento() {
eventoSelecionado.value = null;
eventoBusy.value = false;
}
// ── Actions do MelissaEventoPanel ──────────────────────────────
// updateStatus: muda status no DB e refetcha agenda. Pattern espelha
// AgendaTerapeutaPage (sem optimistic update por simplicidade no MVP).
async function updateEventoStatus(novoStatus, msgSucesso) {
const ev = eventoSelecionado.value;
if (!ev?.id || eventoBusy.value) return;
eventoBusy.value = true;
try {
const { error } = await supabase
.from('agenda_eventos')
.update({ status: novoStatus })
.eq('id', ev.id);
if (error) throw error;
toast.add({ severity: 'success', summary: msgSucesso, life: 2200 });
// Refetch:
// - 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 a partir do resumo)
const filtroTipo = ref(null);
function toggleFiltro(tipo) {
filtroTipo.value = filtroTipo.value === tipo ? null : tipo;
}
function limparFiltro() {
filtroTipo.value = null;
}
const eventosVisiveis = computed(() => {
if (!filtroTipo.value) return eventosHojeReais.value;
return eventosHojeReais.value.filter((ev) => ev.tipo === filtroTipo.value);
});
const filtroLabel = computed(() => {
if (!filtroTipo.value) return '';
const map = { sessao: 'atendimentos', supervisao: 'supervisões', reuniao: 'reuniões' };
return map[filtroTipo.value] || '';
});
function eventStyle(ev) {
const total = HORA_FIM.value - HORA_INICIO.value;
const left = ((ev.startH - HORA_INICIO.value) / total) * 100;
const width = ((ev.endH - ev.startH) / total) * 100;
return {
left: `${left}%`,
width: `${width}%`,
backgroundColor: ev.color,
// Expõe a cor pra CSS (glow/pulse usa color-mix com essa var).
'--ev-color': ev.color
};
}
// ── Status helpers — classes + ícones aplicados nas pílulas ──
function statusKey(ev) {
const s = String(ev?.status || '').toLowerCase();
if (s === 'realizada') return 'realizado';
if (s === 'cancelada') return 'cancelado';
return s || 'agendado';
}
function statusIcon(ev) {
const s = statusKey(ev);
if (s === 'realizado') return 'pi pi-check';
if (s === 'faltou') return 'pi pi-times';
if (s === 'cancelado') return 'pi pi-ban';
if (s === 'remarcar') return 'pi pi-refresh';
return null;
}
function isEvEmCurso(ev) {
const s = statusKey(ev);
if (s === 'realizado' || s === 'cancelado' || s === 'faltou') return false;
const d = now.value;
const h = d.getHours() + d.getMinutes() / 60;
return typeof ev?.startH === 'number' && typeof ev?.endH === 'number'
&& h >= ev.startH && h < ev.endH;
}
function pillStatusClass(ev) {
const s = statusKey(ev);
return [
`tl-pill--${s}`,
{ 'tl-pill--em-curso': isEvEmCurso(ev) }
];
}
const nowCursorLeft = computed(() => {
const d = now.value;
const h = d.getHours() + d.getMinutes() / 60;
if (h < HORA_INICIO.value || h > HORA_FIM.value) return '-100%';
const total = HORA_FIM.value - HORA_INICIO.value;
return `${((h - HORA_INICIO.value) / total) * 100}%`;
});
// Visualização vertical (< lg) — tipo calendário "dia"
const VT_HOUR_PX = 48; // altura de cada slot de hora em px
function eventStyleVertical(ev) {
return {
top: `${(ev.startH - HORA_INICIO.value) * VT_HOUR_PX}px`,
height: `${(ev.endH - ev.startH) * VT_HOUR_PX}px`,
backgroundColor: ev.color
};
}
const nowCursorTop = computed(() => {
const d = now.value;
const h = d.getHours() + d.getMinutes() / 60;
if (h < HORA_INICIO.value || h > HORA_FIM.value) return '-100%';
return `${(h - HORA_INICIO.value) * VT_HOUR_PX}px`;
});
// ───────────────────────────────────────────────────────────────
// 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
const toqueTermino = ref('sino'); // TODO: persistir nas configs do tenant
function abrirCronometro() {
cronoRef.value?.abrir();
}
function fecharCronometro() {
cronoRef.value?.fechar();
}
function testarToque() {
playToque(toqueTermino.value);
}
// 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,
setPrimary,
setSurface,
setDark,
// bg + opacidades + handler do input <type="file">
bgUrl,
overlayOpacity,
bgImageOpacity,
onFileChange,
clearBg,
// relógio
use24h,
// cronômetro
toqueTermino,
testarToque
});
// ───────────────────────────────────────────────────────────────
// Persistência de prefs UI
// ───────────────────────────────────────────────────────────────
// Camadas:
// 1. localStorage — cache rápido pra evitar flash no boot e hold do bgUrl
// (data URL pesada não vai pro DB)
// 2. user_settings.melissa_prefs (jsonb) — fonte de verdade pras prefs
// pequenas (toque, opacidades, formato hora, cards). Sobrevive a
// troca de navegador/dispositivo.
//
// Fluxo: onMounted → load localStorage (paint imediato) → load DB (sobrescreve
// se houver). Watch das refs → save localStorage (sync) + save DB (debounced).
// Sanitiza um payload de prefs (DB ou localStorage — ambos são input externo)
function applyPrefsPayload(prefs) {
if (!prefs || typeof prefs !== 'object') return;
if (typeof prefs.toqueTermino === 'string' && TOQUE_IDS.has(prefs.toqueTermino)) {
toqueTermino.value = prefs.toqueTermino;
}
const op = Number(prefs.overlayOpacity);
if (Number.isFinite(op) && op >= 0 && op <= 0.8) {
overlayOpacity.value = op;
}
const bo = Number(prefs.bgImageOpacity);
if (Number.isFinite(bo) && bo >= 0.01 && bo <= 1) {
bgImageOpacity.value = bo;
}
if (typeof prefs.use24h === 'boolean') {
use24h.value = prefs.use24h;
}
if (Array.isArray(prefs.cardsAtivos)) {
const valid = prefs.cardsAtivos.filter((id) => typeof id === 'string' && CARD_IDS.has(id));
if (valid.length > 0) cardsAtivos.value = valid;
}
if (prefs.cardsLayout === 'linha-unica' || prefs.cardsLayout === 'duas-linhas') {
cardsLayout.value = prefs.cardsLayout;
}
}
function currentPrefsSnapshot() {
return {
toqueTermino: toqueTermino.value,
overlayOpacity: overlayOpacity.value,
bgImageOpacity: bgImageOpacity.value,
use24h: use24h.value,
cardsAtivos: cardsAtivos.value,
cardsLayout: cardsLayout.value
};
}
function saveLayoutPrefs() {
// localStorage inclui bgUrl (data URL); DB não — bgUrl fica local
const prefs = { ...currentPrefsSnapshot(), bgUrl: bgUrl.value || '' };
try {
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(prefs));
} catch {
// Quota provavelmente estourou por causa do bgUrl (data URL grande).
// Tenta sem ele — as outras prefs são minúsculas.
try {
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ ...prefs, bgUrl: '' }));
} catch {}
}
}
function loadLocalPrefs() {
let raw;
try { raw = localStorage.getItem(LAYOUT_STORAGE_KEY); } catch { return; }
if (!raw) return;
let prefs;
try { prefs = JSON.parse(raw); } catch { return; }
if (!prefs || typeof prefs !== 'object') return;
applyPrefsPayload(prefs);
// bgUrl: aceita só data URL de imagem (evita injeção de URL externa via storage)
if (typeof prefs.bgUrl === 'string' && prefs.bgUrl.startsWith('data:image/')) {
bgUrl.value = prefs.bgUrl;
}
}
// ── DB sync ────────────────────────────────────────────────────
let dbSaveTimer = null;
let dbReady = false; // só salva no DB depois do load inicial (evita sobrescrever c/ defaults)
async function loadDbPrefs() {
try {
const { data: u } = await supabase.auth.getUser();
const uid = u?.user?.id;
if (!uid) return;
const { data, error } = await supabase
.from('user_settings')
.select('melissa_prefs')
.eq('user_id', uid)
.maybeSingle();
if (error) return;
if (data?.melissa_prefs && typeof data.melissa_prefs === 'object') {
applyPrefsPayload(data.melissa_prefs);
}
} catch {
// silencioso — falha de rede/auth não pode quebrar a UI
} finally {
dbReady = true;
}
}
async function saveDbPrefs() {
if (!dbReady) return;
try {
const { data: u } = await supabase.auth.getUser();
const uid = u?.user?.id;
if (!uid) return;
await supabase
.from('user_settings')
.upsert(
{ user_id: uid, melissa_prefs: currentPrefsSnapshot(), updated_at: new Date().toISOString() },
{ onConflict: 'user_id' }
);
} catch {
// silencioso — pref save não-crítico; localStorage segura o estado até voltar
}
}
function queueDbSave() {
if (dbSaveTimer) clearTimeout(dbSaveTimer);
dbSaveTimer = setTimeout(saveDbPrefs, 600);
}
// Salva em qualquer mudança das prefs (deep no array de cardsAtivos pra pegar splice/push)
watch(
[toqueTermino, overlayOpacity, bgImageOpacity, use24h, bgUrl, cardsAtivos, cardsLayout],
() => {
saveLayoutPrefs();
queueDbSave();
},
{ deep: true }
);
// Carrega antes do paint (use onMounted; o flash inicial é aceitável)
onMounted(async () => {
loadLocalPrefs(); // sync: paint imediato com valores cached
await loadDbPrefs(); // async: sobrescreve com valores autoritativos do DB
});
// Auto-scroll da timeline horizontal — registrado em onMounted pra
// garantir que agendaWorkRules / agendaSettings já saíram do TDZ.
// Watch dispara uma vez quando o range fica definido (rules carregam
// async). `stop()` no primeiro scroll válido pra não brigar com user.
// Também mantém o tlScrollState atualizado pra alimentar o eco lateral.
onMounted(() => {
const stop = watch(
[HORA_INICIO, HORA_FIM],
() => {
nextTick(() => {
if (!_tlAutoScrolled) {
_scrollTimelineToNow();
const el = tlHScrollEl.value;
const inner = el?.firstElementChild;
if (inner && inner.scrollWidth > el.clientWidth) {
_tlAutoScrolled = true;
stop();
}
}
// Sempre re-mede após mudança de range (innerWidth muda).
_updateTlScrollState();
});
},
{ immediate: true }
);
// ResizeObserver pra atualizar o eco quando a viewport muda
// (ex: user redimensiona janela, abre/fecha sidebar, etc).
const el = tlHScrollEl.value;
if (el && typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(() => _updateTlScrollState());
ro.observe(el);
if (el.firstElementChild) ro.observe(el.firstElementChild);
onBeforeUnmount(() => ro.disconnect());
}
});
// ───────────────────────────────────────────────────────────────
// 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 }">
<!-- 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">
<div
v-if="settingsOpen"
class="glass-panel absolute top-12 right-0 w-72 p-4 text-white/95"
>
<div class="text-xs uppercase tracking-widest text-white/60 mb-3">Personalização</div>
<button
class="w-full text-left px-3 py-2 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 mb-1.5 flex items-center gap-2 text-sm"
@click="pickFile"
>
<i class="pi pi-image" />
Trocar imagem de fundo
</button>
<p class="text-[0.68rem] leading-snug text-white/50 px-1 mb-2.5">
Recomendado: 1920×1080 (Full HD), JPG ou PNG. Tamanho máximo: 2&nbsp;MB.
</p>
<button
v-if="bgUrl"
class="w-full text-left px-3 py-2 rounded-lg hover:bg-white/10 mb-3 flex items-center gap-2 text-sm text-white/70"
@click="clearBg"
>
<i class="pi pi-refresh" />
Voltar ao padrão
</button>
<input ref="fileInput" type="file" accept="image/*" hidden @change="onFileChange" />
<div v-if="bgUrl" class="mb-3">
<label class="text-xs text-white/60 mb-1.5 block">
Transparência da imagem: {{ Math.round(bgImageOpacity * 100) }}%
</label>
<input
v-model.number="bgImageOpacity"
type="range"
min="0.01"
max="1"
step="0.01"
class="settings-range w-full"
/>
</div>
<div class="mb-3">
<label class="text-xs text-white/60 mb-1.5 block">
Opacidade do fundo: {{ Math.round(overlayOpacity * 100) }}%
</label>
<input
v-model.number="overlayOpacity"
type="range"
min="0"
max="0.8"
step="0.05"
class="settings-range w-full"
/>
</div>
<div class="mb-3">
<label class="text-xs text-white/60 mb-1.5 block">Toque de término</label>
<div class="flex gap-1.5">
<select v-model="toqueTermino" class="settings-select flex-1">
<option v-for="t in TOQUES" :key="t.id" :value="t.id">
{{ t.label }}
</option>
</select>
<button
class="settings-test-btn"
title="Testar"
:disabled="toqueTermino === 'nenhum'"
@click="testarToque"
>
<i class="pi pi-play text-[0.65rem]" />
</button>
</div>
</div>
<div class="flex items-center justify-between text-sm mb-3">
<span class="text-white/80">
Formato 24h
<span class="text-white/45 text-[0.7rem]">(relógio)</span>
</span>
<button
class="w-10 h-6 rounded-full transition-colors relative settings-toggle"
:class="use24h ? 'is-on' : 'bg-white/20'"
@click="use24h = !use24h"
>
<span
class="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all"
:style="{ left: use24h ? '1.125rem' : '0.125rem' }"
/>
</button>
</div>
<div class="flex items-center justify-between text-sm mb-3">
<span class="text-white/80">Modo escuro</span>
<button
class="w-10 h-6 rounded-full transition-colors relative settings-toggle"
:class="isDarkTheme ? 'is-on' : 'bg-white/20'"
@click="setDark(!isDarkTheme)"
:title="isDarkTheme ? 'Mudar pra claro' : 'Mudar pra escuro'"
>
<span
class="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all"
:style="{ left: isDarkTheme ? '1.125rem' : '0.125rem' }"
/>
</button>
</div>
<div>
<label class="text-xs text-white/60 mb-1.5 block">Cor primária</label>
<div class="grid grid-cols-9 gap-1.5">
<button
v-for="pc in PRIMARY_COLORS"
:key="pc.name"
class="settings-swatch"
:class="{ 'is-active': layoutConfig.primary === pc.name }"
:style="{ backgroundColor: pc.swatch === 'currentColor' ? 'var(--m-text)' : pc.swatch }"
:title="pc.name"
@click="setPrimary(pc.name)"
/>
</div>
</div>
<div>
<label class="text-xs text-white/60 mb-1.5 block">Surface</label>
<div class="grid grid-cols-8 gap-1.5">
<button
v-for="sf in SURFACES"
:key="sf.name"
class="settings-swatch"
:class="{ 'is-active': activeSurface === sf.name }"
:style="{ backgroundColor: sf.palette['500'] }"
:title="sf.name"
@click="setSurface(sf.name)"
/>
</div>
</div>
</div>
</Transition>
</div>
<!-- Conteúdo central -->
<div class="win11-summary__inner">
<!-- Bloco hero: relógio + data + saudação -->
<header class="text-center text-white drop-shadow-lg select-none">
<div class="inline-flex items-center gap-6">
<div class="clock-display">{{ horaFormatada }}</div>
<button
class="crono-icon-btn"
title="Cronômetro de sessão"
@click="abrirCronometro"
>
<i class="pi pi-stopwatch text-xl text-white/85" />
</button>
</div>
<div class="text-lg md:text-xl font-light tracking-wide text-white/90 mt-1 capitalize">
{{ dataExtenso }}
</div>
<div class="text-2xl md:text-3xl font-light mt-6 text-white/95 tracking-tight">
{{ saudacao }}, <span class="font-normal">Dr. Leonardo</span>.
</div>
<div class="text-base md:text-lg font-light text-white/70 mt-2 tracking-wide">
<template v-if="resumoPartes.length === 0">
Sua agenda está livre hoje.
</template>
<template v-else>
Hoje
<template v-for="(p, i) in resumoPartes" :key="p.tipo">
<button
type="button"
class="resumo-link"
:class="{ 'is-active': filtroTipo === p.tipo }"
@click="toggleFiltro(p.tipo)"
>{{ p.text }}</button><span v-if="i < resumoPartes.length - 2">,&nbsp;</span><span v-else-if="i === resumoPartes.length - 2">&nbsp;e&nbsp;</span>
</template>.
</template>
</div>
</header>
<!-- Busca rápida (entre o "Hoje " e a timeline) -->
<MelissaBusca
class="mt-8"
:pacientes="pacientesReais"
:eventos="eventosHojeReais"
@acao="abrirSecao"
@paciente="() => abrirSecao('pacientes')"
@evento="abrirEvento"
/>
<!-- Timeline horizontal -->
<section class="glass-panel mt-8 px-5 py-4 max-w-5xl w-full mx-auto">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2 text-white/90 text-sm font-medium">
<i class="pi pi-clock text-xs" />
Linha do tempo Hoje
<span
v-if="todayFeriado"
class="tl-day-badge tl-day-badge--feriado"
:title="`Feriado: ${todayFeriado.nome}`"
>
<i class="pi pi-star text-[0.6rem]" />
Feriado{{ todayFeriado.nome ? `: ${todayFeriado.nome}` : '' }}
</span>
<span
v-else-if="isFolga"
class="tl-day-badge tl-day-badge--folga"
title="Hoje não é dia de trabalho na sua agenda — sessões fora do expediente continuam permitidas."
>
<i class="pi pi-moon text-[0.6rem]" />
Folga
</span>
<button
v-if="filtroTipo"
type="button"
class="filtro-chip"
@click="limparFiltro"
:title="`Mostrando apenas ${filtroLabel}. Clique pra mostrar tudo.`"
>
{{ filtroLabel }}
<i class="pi pi-times text-[0.6rem]" />
</button>
</div>
<div class="text-xs text-white/70 flex items-center gap-1.5">
<span class="pulse-dot w-2.5 h-0.5 rounded-full bg-red-500" />
Agora
</div>
</div>
<!-- Horizontal (lg+) scroll horizontal quando o range é
grande (ex: jornada 02h23h). min-width por slot
garante legibilidade dos labels e das pílulas.
Auto-scroll inicial centra "agora" na viewport.
Frame relativo abriga o eco lateral (overlay). -->
<div class="tl-h-frame hidden lg:block">
<div ref="tlHScrollEl" class="tl-h-scroll" @scroll.passive="onTimelineScroll">
<div class="tl-h-inner relative" :style="{ '--m-tl-cols': HORA_FIM - HORA_INICIO }">
<div class="flex justify-between mb-1">
<div v-for="h in hoursRange" :key="h" class="flex-1 text-left">
<span class="text-[0.65rem] text-white/55 font-medium">{{ h }}h</span>
</div>
</div>
<div class="relative h-9 bg-white/5 rounded-md overflow-visible border border-white/10">
<div
v-for="ev in eventosVisiveis"
:key="ev.id"
class="tl-event-pill absolute h-[30px] rounded flex items-center px-2 overflow-hidden cursor-pointer min-w-[32px] hover:brightness-110 transition-[filter,opacity] duration-200 z-10"
:class="pillStatusClass(ev)"
:style="eventStyle(ev)"
:title="ev.label"
@click="abrirEvento(ev)"
>
<span class="tl-event-pill__label text-[0.8rem] font-semibold truncate">{{ ev.label }}</span>
<i
v-if="statusIcon(ev)"
:class="['tl-event-pill__status', statusIcon(ev)]"
aria-hidden="true"
/>
</div>
<div
class="absolute top-0 h-full flex flex-col items-center pointer-events-none z-20"
:style="{ left: nowCursorLeft }"
>
<div class="w-0.5 h-full bg-red-500 opacity-90" />
<div class="absolute -top-0.5 w-[7px] h-[7px] rounded-full bg-red-500" />
</div>
</div>
</div>
</div>
<!-- Eco lateral minimap pulsante de cores. Tracinhos
posicionados por tempo, mostrando "forma" do dia
off-screen. Click suaviza scroll até o evento. -->
<div
v-if="tlEcoState.left.length"
class="tl-eco tl-eco--left"
:title="`${tlEcoState.left.length} antes — clique pra centralizar`"
>
<button
v-for="ev in tlEcoState.left"
:key="`eco-l-${ev.id}`"
type="button"
class="tl-eco__tick"
:style="ecoTickStyle(ev, 'left')"
:title="`${fmtHora(ev.startH)} · ${ev.label}`"
@click="scrollToEvent(ev)"
/>
</div>
<div
v-if="tlEcoState.right.length"
class="tl-eco tl-eco--right"
:title="`${tlEcoState.right.length} à frente — clique pra centralizar`"
>
<button
v-for="ev in tlEcoState.right"
:key="`eco-r-${ev.id}`"
type="button"
class="tl-eco__tick"
:style="ecoTickStyle(ev, 'right')"
:title="`${fmtHora(ev.startH)} · ${ev.label}`"
@click="scrollToEvent(ev)"
/>
</div>
</div>
<!-- Vertical (< lg) tipo calendário "dia" -->
<div class="vt lg:hidden" :style="{ '--m-vt-rows': HORA_FIM - HORA_INICIO }">
<div
v-for="h in hoursRange"
:key="h"
class="vt-row"
:style="{ top: `${(h - HORA_INICIO) * VT_HOUR_PX}px` }"
>
<span class="vt-hour">{{ h }}h</span>
<div class="vt-line" />
</div>
<div
v-for="ev in eventosVisiveis"
:key="ev.id"
class="vt-event hover:brightness-110 transition-[filter,opacity] duration-200"
:class="pillStatusClass(ev)"
:style="eventStyleVertical(ev)"
:title="ev.label"
@click="abrirEvento(ev)"
>
<i
v-if="statusIcon(ev)"
:class="['vt-event__status', statusIcon(ev)]"
aria-hidden="true"
/>
<div class="vt-event-time">
{{ fmtHora(ev.startH) }} {{ fmtHora(ev.endH) }}
</div>
<div class="vt-event-label">{{ ev.label }}</div>
</div>
<div class="vt-now" :style="{ top: nowCursorTop }">
<div class="vt-now-dot" />
<div class="vt-now-line" />
</div>
</div>
</section>
<!-- Cards (catálogo + ativos + layout switchável) -->
<section
class="cards-shell mt-5 w-full mx-auto"
:class="{ 'is-wrap': cardsLayout === 'duas-linhas' }"
>
<div
class="cards-row"
:class="cardsLayout === 'linha-unica' ? 'cards-row--linha' : 'cards-row--wrap'"
>
<template v-for="cardId in cardsAtivos" :key="cardId">
<!-- Próximo paciente -->
<MelissaCard
v-if="cardId === 'proximo-paciente'"
icon="pi pi-user"
icon-color="text-emerald-300"
title="Próximo paciente"
:action-title="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 -->
<!-- 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"
/>
</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"
/>
<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"
/>
<!-- 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;
}
/* ─── Plano de trás (resumo) ───────────────────────────────── */
.win11-summary {
position: relative;
z-index: 10;
width: 100%;
height: 100%;
transition: filter 320ms ease, transform 320ms ease;
}
.win11-summary.is-behind {
filter: blur(14px) brightness(0.7);
transform: scale(0.98);
pointer-events: none;
}
.win11-summary__inner {
width: 100%;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
align-items: center;
/* `safe center`: centraliza vertical quando o conteúdo cabe;
vira flex-start (sem clipar) quando excede e exige scroll */
justify-content: safe center;
padding: 4rem 2rem 6rem;
/* Scrollbar discreta */
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.win11-summary__inner::-webkit-scrollbar {
width: 6px;
}
.win11-summary__inner::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* ─── Relógio ──────────────────────────────────────────────── */
.clock-display {
font-size: clamp(5rem, 12vw, 9rem);
font-weight: 200;
line-height: 1;
letter-spacing: -0.04em;
font-variant-numeric: tabular-nums;
color: white;
text-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
/* ─── Glass primitives ─────────────────────────────────────── */
.glass-panel {
background: var(--m-bg-soft);
backdrop-filter: blur(24px) saturate(140%);
-webkit-backdrop-filter: blur(24px) saturate(140%);
border: 1px solid var(--m-border);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.glass-btn {
background: var(--m-bg-soft);
backdrop-filter: blur(20px);
border: 1px solid var(--m-border-strong);
border-radius: 9999px;
transition: background-color 160ms ease, 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);
}
/* ─── Settings popover: select + botão testar do toque ────── */
.settings-select {
background: var(--m-bg-soft);
border: 1px solid var(--m-border-strong);
color: var(--m-text);
padding: 7px 10px;
border-radius: 8px;
font-size: 0.8rem;
outline: none;
appearance: none;
-webkit-appearance: none;
cursor: pointer;
min-width: 0;
}
.settings-select:hover {
background: var(--m-bg-soft-hover);
}
.settings-select option {
/* renderizado pelo OS — usa tokens semânticos pra acompanhar dark/light */
background: var(--p-content-background);
color: var(--m-text);
}
.settings-test-btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
background: var(--m-bg-soft);
border: 1px solid var(--m-border-strong);
color: var(--m-text);
border-radius: 8px;
cursor: pointer;
transition: background-color 140ms ease;
}
.settings-test-btn:hover {
background: var(--m-accent);
border-color: var(--m-accent);
}
.settings-test-btn:disabled {
background: var(--m-bg-soft);
border-color: var(--m-border);
color: var(--m-text-faint);
cursor: not-allowed;
}
/* Sliders do painel de personalização — usa accent-color (pinta thumb +
parte preenchida na primary). Não sobrescrevemos ::-webkit-slider-track
pra preservar o alinhamento vertical nativo da bolinha (Chrome entra
em modo "custom" se a track for estilizada e a thumb desce). */
.settings-range {
accent-color: var(--p-primary-color);
width: 100%;
}
/* Toggle "ligado" usa a primary do preset escolhido (não emerald hardcoded
como antes — agora todo o painel reflete a paleta selecionada). */
.settings-toggle.is-on {
background-color: var(--p-primary-color);
}
/* Swatches de cor primária — círculos compactos com ring na ativa. */
.settings-swatch {
width: 22px;
height: 22px;
border-radius: 9999px;
border: 1px solid rgba(255, 255, 255, 0.18);
cursor: pointer;
transition: transform 120ms ease, box-shadow 120ms ease;
padding: 0;
}
.settings-swatch:hover {
transform: scale(1.12);
}
.settings-swatch.is-active {
box-shadow:
0 0 0 2px var(--m-bg-medium),
0 0 0 4px var(--p-primary-color);
}
/* ─── Botão-trigger do cronômetro (irmão flex do relógio) ───── */
.crono-icon-btn {
width: 48px;
height: 48px;
border-radius: 12px;
background: var(--m-bg-soft-hover);
backdrop-filter: blur(20px) saturate(160%);
-webkit-backdrop-filter: blur(20px) saturate(160%);
border: 1px solid var(--m-border-strong);
display: grid;
place-items: center;
cursor: pointer;
transition: all 200ms ease;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.2);
flex-shrink: 0;
}
.crono-icon-btn:hover {
background: var(--m-border-strong);
transform: scale(1.06);
}
/* ─── Seção: overlay grande (Pacientes, WhatsApp, etc.) ─────── */
.secao-layer {
position: absolute;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.secao-panel {
width: min(1100px, 100%);
height: min(80vh, 720px);
background: var(--m-bg-medium);
backdrop-filter: blur(32px) saturate(160%);
-webkit-backdrop-filter: blur(32px) saturate(160%);
border: 1px solid var(--m-border-strong);
border-radius: 22px;
padding: 1.75rem;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
}
.secao-icon {
width: 44px;
height: 44px;
border-radius: 12px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border-strong);
display: grid;
place-items: center;
flex-shrink: 0;
}
.secao-body {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: auto;
padding: 1rem;
}
.secao-body-icon {
font-size: 4.5rem;
color: var(--m-text-faint);
margin-bottom: 1.5rem;
}
/* (Antes vivia aqui o CSS do panel inline de evento — extraído pra
MelissaEventoPanel.vue em 2026-04-27 junto com o componente.) */
/* (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;
}
}
/* ─── Links clicáveis dentro do resumo do dia ─────────────── */
.resumo-link {
display: inline;
background: none;
border: none;
padding: 0;
margin: 0;
font: inherit;
color: var(--m-text);
cursor: pointer;
border-bottom: 1px dashed var(--m-border-strong);
transition: color 160ms ease, border-color 160ms ease;
}
.resumo-link:hover {
color: white;
border-bottom-color: var(--m-text-muted);
}
.resumo-link.is-active {
color: white;
font-weight: 500;
border-bottom: 1px solid var(--m-text);
}
/* ─── Chip do filtro ativo (header da timeline) ────────────── */
.filtro-chip {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 6px;
padding: 2px 8px 2px 10px;
border-radius: 9999px;
background: var(--m-bg-soft-hover);
border: 1px solid var(--m-border-strong);
color: var(--m-text);
font-size: 0.7rem;
font-weight: 500;
cursor: pointer;
transition: background-color 140ms ease;
}
.filtro-chip:hover {
background: var(--m-border-strong);
}
/* ─── Badge "Folga" / "Feriado" no header da timeline ──────
Sinaliza dia não-útil sem bloquear: sessões fora do expediente
continuam permitidas e visíveis na timeline. */
.tl-day-badge {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 6px;
padding: 2px 8px;
border-radius: 9999px;
font-size: 0.7rem;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
}
.tl-day-badge--folga {
background: var(--m-bg-soft-hover);
color: var(--m-text-muted);
border: 1px solid var(--m-border-strong);
}
.tl-day-badge--feriado {
background: color-mix(in srgb, rgb(245, 158, 11) 18%, transparent);
color: rgb(245, 158, 11);
border: 1px solid color-mix(in srgb, rgb(245, 158, 11) 38%, transparent);
}
html:not(.app-dark) .win11-root .tl-day-badge--feriado {
background: color-mix(in srgb, rgb(217, 119, 6) 12%, transparent);
color: rgb(180, 83, 9); /* amber-700 — legível em fundo claro */
border-color: color-mix(in srgb, rgb(217, 119, 6) 32%, transparent);
}
/* ─── Timeline vertical (< lg) — tipo calendário dia ─────── */
.vt {
position: relative;
/* --m-vt-rows = HORA_FIM - HORA_INICIO (set inline; fallback 12 = 8→20) */
height: calc(var(--m-vt-rows, 12) * 48px + 24px);
margin-top: 0.75rem;
}
.vt-row {
position: absolute;
left: 0;
right: 0;
height: 0;
display: flex;
align-items: center;
}
.vt-hour {
width: 36px;
text-align: right;
padding-right: 8px;
color: var(--m-text-muted);
font-size: 0.65rem;
font-weight: 500;
flex-shrink: 0;
/* O texto fica visualmente centrado na linha (transform shift up half) */
transform: translateY(-50%);
background: transparent;
line-height: 1;
}
.vt-line {
flex: 1;
height: 1px;
background: var(--m-bg-soft);
}
.vt-event {
position: absolute;
left: 44px;
right: 4px;
padding: 4px 10px;
border-radius: 4px;
color: white;
cursor: pointer;
overflow: hidden;
z-index: 1;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 24px;
}
.vt-event-time {
font-size: 0.62rem;
opacity: 0.85;
line-height: 1.1;
}
.vt-event-label {
font-size: 0.78rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
}
/* Timeline horizontal — label da pílula sempre branca (sobre cor saturada).
Em light, o override de `.text-white` no fim do arquivo flipa pra dark
e quebra contraste sobre indigo/verde/vermelho. Forçar branco aqui. */
.tl-event-pill__label {
color: #fff;
flex: 1;
min-width: 0;
}
/* ─── Status overlays nas pílulas (horizontal + vertical) ──────
Cada status ganha um tratamento visual específico — ícone no canto
+ variação de opacidade/borda/hatch. A var --ev-color (setada no
inline style do eventStyle) alimenta o pulse "em curso" com a cor
do próprio evento. */
.tl-event-pill {
/* já é position: absolute pelas Tailwind classes existentes */
transition: filter 200ms ease, opacity 200ms ease, box-shadow 240ms ease;
}
.tl-event-pill__status,
.vt-event__status {
flex-shrink: 0;
display: inline-grid;
place-items: center;
width: 14px;
height: 14px;
border-radius: 9999px;
background: rgba(0, 0, 0, 0.28);
color: #fff;
font-size: 0.55rem;
margin-left: 6px;
line-height: 1;
}
.vt-event__status {
position: absolute;
top: 4px;
right: 4px;
width: 16px;
height: 16px;
font-size: 0.6rem;
margin-left: 0;
}
.vt-event {
position: absolute; /* já era; reforça pra absolute do __status */
}
/* Realizado: glow verde sutil (a cor do bg já é verde) — celebra o feito */
.tl-pill--realizado {
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.28), 0 4px 12px rgba(16, 185, 129, 0.18);
}
/* Faltou: opacidade reduzida + label tachado */
.tl-pill--faltou {
opacity: 0.78;
}
.tl-pill--faltou .tl-event-pill__label,
.tl-pill--faltou .vt-event-label {
text-decoration: line-through;
text-decoration-color: rgba(255, 255, 255, 0.55);
text-decoration-thickness: 1.5px;
}
/* Cancelado: hatching diagonal + opacidade + label tachado.
Mantém o backgroundColor do ev.color (cinza por pickColor). */
.tl-pill--cancelado {
opacity: 0.6;
background-image: repeating-linear-gradient(
135deg,
rgba(255, 255, 255, 0.0) 0,
rgba(255, 255, 255, 0.0) 4px,
rgba(255, 255, 255, 0.22) 4px,
rgba(255, 255, 255, 0.22) 6px
);
}
.tl-pill--cancelado .tl-event-pill__label,
.tl-pill--cancelado .vt-event-label {
text-decoration: line-through;
text-decoration-color: rgba(255, 255, 255, 0.5);
}
/* Remarcar: ring âmbar puxa atenção (status transiente, precisa decisão) */
.tl-pill--remarcar {
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.6), 0 4px 14px rgba(245, 158, 11, 0.25);
}
/* Em curso: pulse glow na cor do próprio evento via --ev-color.
Compute via color-mix pra herdar a hue. */
.tl-pill--em-curso {
animation: tl-pill-em-curso 2.2s ease-in-out infinite;
z-index: 12;
}
@keyframes tl-pill-em-curso {
0%, 100% {
box-shadow:
0 0 0 0 color-mix(in srgb, var(--ev-color, #6366f1) 55%, transparent),
0 0 0 1px rgba(255, 255, 255, 0.14);
}
50% {
box-shadow:
0 0 0 6px color-mix(in srgb, var(--ev-color, #6366f1) 0%, transparent),
0 0 18px 2px color-mix(in srgb, var(--ev-color, #6366f1) 60%, transparent);
}
}
/* ─── Timeline horizontal: scroll quando o range é grande ────
--m-tl-cols (set inline) = HORA_FIM - HORA_INICIO
--m-tl-slot-w = largura mínima por hora (default 80px).
Inner stretch atinge (cols * slot-w). Container externo rola
horizontal quando passa do viewport. */
.tl-h-scroll {
overflow-x: auto;
overflow-y: visible;
/* respiro pra pílulas e cursor "Agora" não cortarem na borda */
padding-bottom: 4px;
/* scrollbar discreta — combina com o tom do panel */
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.tl-h-scroll::-webkit-scrollbar {
height: 6px;
}
.tl-h-scroll::-webkit-scrollbar-track {
background: transparent;
}
.tl-h-scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 9999px;
}
.tl-h-inner {
min-width: calc(var(--m-tl-cols, 12) * var(--m-tl-slot-w, 80px));
}
/* ─── Eco lateral: minimap pulsante de cores nas bordas ────────
Faixas verticais de 8px coladas nas bordas do scroll, mostrando
tracinhos coloridos (cor do status) pra cada evento off-screen.
Posição vertical de cada tracinho é proporcional ao tempo do
evento dentro da janela invisível — você lê a "forma" do dia
off-screen. Pulse sutil 2.4s só quando há algo escondido. */
.tl-h-frame {
position: relative;
}
.tl-eco {
position: absolute;
top: 16px; /* alinha com o topo da barra (descontando linha de horas) */
bottom: 8px; /* respiro do scrollbar */
width: 8px;
z-index: 6;
pointer-events: auto;
border-radius: 4px;
background: color-mix(in srgb, var(--m-bg-soft) 70%, transparent);
border: 1px solid var(--m-border);
box-shadow: 0 0 0 0 transparent;
animation: tl-eco-pulse 2400ms ease-in-out infinite;
/* Transição entre estados — evita flicker quando some/aparece */
transition: opacity 180ms ease;
}
.tl-eco--left { left: -2px; }
.tl-eco--right { right: -2px; }
.tl-eco__tick {
position: absolute;
left: 1px;
right: 1px;
height: 4px;
transform: translateY(-50%);
border: 0;
padding: 0;
border-radius: 2px;
cursor: pointer;
opacity: 0.85;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15);
transition: opacity 140ms ease, transform 140ms ease, height 140ms ease;
}
.tl-eco__tick:hover {
opacity: 1;
height: 6px;
/* Espelha pra fora pra "estourar" da faixa quando hover */
transform: translateY(-50%) scaleX(2.2);
z-index: 1;
}
.tl-eco--left .tl-eco__tick:hover { transform-origin: left center; }
.tl-eco--right .tl-eco__tick:hover { transform-origin: right center; }
@keyframes tl-eco-pulse {
0%, 100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--p-primary-color) 0%, transparent);
}
50% {
box-shadow: 0 0 10px 1px color-mix(in srgb, var(--p-primary-color) 35%, transparent);
}
}
/* Light mode: faixa precisa contrastar mais com bloom claro */
html:not(.app-dark) .win11-root .tl-eco {
background: color-mix(in srgb, var(--m-bg-soft-hover) 90%, transparent);
border-color: var(--m-border-strong);
}
.vt-now {
position: absolute;
left: 32px;
right: 0;
z-index: 2;
pointer-events: none;
display: flex;
align-items: center;
transform: translateY(-50%); /* centro do cursor sobre a linha do horário */
}
.vt-now-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgb(239, 68, 68);
flex-shrink: 0;
animation: pulse 1.6s ease-in-out infinite;
}
.vt-now-line {
flex: 1;
height: 2px;
background: rgb(239, 68, 68);
box-shadow: 0 0 6px rgba(239, 68, 68, 0.5);
margin-left: 2px;
}
/* ─── Pulse no "Agora" ─────────────────────────────────────── */
.pulse-dot {
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ─── Transições ───────────────────────────────────────────── */
.lift-enter-active,
.lift-leave-active {
transition: opacity 240ms ease, transform 280ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
.lift-enter-from {
opacity: 0;
transform: scale(0.96) translateY(12px);
}
.lift-leave-to {
opacity: 0;
transform: scale(0.98) translateY(8px);
}
.settings-pop-enter-active,
.settings-pop-leave-active {
transition: opacity 160ms ease, transform 160ms ease;
}
.settings-pop-enter-from,
.settings-pop-leave-to {
opacity: 0;
transform: translateY(-6px) scale(0.98);
}
/* line-clamp util (caso Tailwind não tenha) */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
<!--
Variáveis de tema Melissa bloco GLOBAL (não scoped) pra
atravessar o scoped CSS dos componentes filhos (MelissaMenu,
MelissaCronometro, MelissaAgenda, MelissaCard, MelissaBusca).
Defaults (modo escuro):
--m-bg-soft bg sutil em superfícies internas (cards, hovers)
--m-bg-medium bg dos painéis flutuantes (panel principal)
--m-text cor de texto principal
--m-text-muted texto secundário
--m-border cor de bordas glass
--m-accent cor primária do tema (PrimeVue --p-primary-color)
--m-accent-soft tint sutil da primária pra active/hover
Em modo light (html sem .app-dark) os valores invertem.
-->
<style>
/* Tokens Melissa derivados dos tokens semânticos do PrimeVue (--p-*) que
já flipam automaticamente com dark/light AND seguem o preset escolhido
(Aura/Lara/Nora/Material têm escalas e radii próprios). */
.win11-root {
/* Cor primária — vem do preset escolhido pelo user */
--m-accent: var(--p-primary-color);
--m-accent-soft: color-mix(in srgb, var(--p-primary-color) 18%, transparent);
--m-accent-strong: color-mix(in srgb, var(--p-primary-color) 35%, transparent);
/* Glass derivado do content/surface tokens com alpha pra preservar
o efeito translúcido sobre a photo. Auto-flip dark/light. */
--m-bg-soft: color-mix(in srgb, var(--p-content-background) 50%, transparent);
--m-bg-soft-hover: color-mix(in srgb, var(--p-content-hover-background, var(--p-surface-100)) 70%, transparent);
--m-bg-medium: color-mix(in srgb, var(--p-content-background) 88%, transparent);
/* Texto e bordas — auto-flip via PrimeVue */
--m-text: var(--p-text-color);
--m-text-muted: var(--p-text-muted-color);
--m-text-faint: color-mix(in srgb, var(--p-text-color) 45%, transparent);
--m-border: var(--p-content-border-color);
--m-border-strong: color-mix(in srgb, var(--p-text-color) 22%, transparent);
/* Border-radius do preset (Aura usa 12px, Nora 6px, Lara 6px etc.) */
--m-radius: var(--p-border-radius, 8px);
--m-radius-lg: calc(var(--p-border-radius, 8px) * 1.5);
/* RGB do dim (overlay sobre a photo). Opacidade vem do slider via
rgba(var(--m-dim-rgb), opacity) no inline style. Único token que
precisa de override manual (PrimeVue não tem "dim" nativo). */
--m-dim-rgb: 0, 0, 0;
/* ─── Gradiente "Bloom" (default = dark) ────────────────────────
Derivado da palette primária + surface do preset. Em light flipam
pra tons claros (200/100 + surface-0/primary-50). O defaultBgStyle
no <script> referencia essas vars; mudar aqui muda o bloom todo. */
--bloom-c1: var(--p-primary-400);
--bloom-c2: var(--p-primary-300);
--bloom-base-1: var(--p-surface-950);
--bloom-base-2: var(--p-primary-950);
/* ─── Pílula de atalho de teclado (psi-kbd) ─────────────────────
Em dark: pílula escura com texto claro. Em light: pílula clara
com texto escuro (override no bloco light abaixo). */
--m-kbd-bg: rgba(0, 0, 0, 0.65);
--m-kbd-text: rgba(255, 255, 255, 0.92);
/* ─── Dock (taskbar transparent) ────────────────────────────────
Altura da faixa inferior que recebe items minimizados (chip do
cronômetro, futuros). Páginas fullscreen reservam esse espaço
no inset bottom pra não ficar atrás do dock. */
--m-dock-h: 76px;
}
/* ─── Dock (global pra atravessar Teleport + evitar perda de scoped
em static-hoisted nodes). Faixa horizontal no bottom. Sem bg.
Items individuais (chip cronômetro, dock-actions, futuros) têm
visuais próprios e posicionam-se dentro do flexbox. */
.melissa-dock {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: var(--m-dock-h, 76px);
z-index: 65; /* abaixo do ψ (70) e overlays modais */
display: flex;
align-items: center;
/* padding-left reserva ~96px pro ψ (56px + 1.25rem left + gap) */
padding: 0 1.5rem 0 6rem;
gap: 12px;
pointer-events: none; /* só os items recebem clique */
}
.melissa-dock > * { pointer-events: auto; }
/* ─── 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);
}
/* 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>