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