diff --git a/src/layout/melissa/MelissaHeroClock.vue b/src/layout/melissa/MelissaHeroClock.vue new file mode 100644 index 0000000..2e80235 --- /dev/null +++ b/src/layout/melissa/MelissaHeroClock.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/src/layout/melissa/MelissaLayout.vue b/src/layout/melissa/MelissaLayout.vue index 4e5af99..59108bb 100644 --- a/src/layout/melissa/MelissaLayout.vue +++ b/src/layout/melissa/MelissaLayout.vue @@ -19,12 +19,16 @@ import { ref, computed, watch, onMounted, onBeforeUnmount, provide, nextTick } f 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 { applyThemeEngine, surfaces as THEME_SURFACES, presetOptions as THEME_PRESETS } 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 MelissaSettingsPanel from './MelissaSettingsPanel.vue'; +import MelissaHeroClock from './MelissaHeroClock.vue'; +import MelissaTimelineHoje from './MelissaTimelineHoje.vue'; +import { useMelissaWallpaper } from './composables/useMelissaWallpaper'; import MelissaAgenda from './MelissaAgenda.vue'; import MelissaPacientes from './MelissaPacientes.vue'; import MelissaCompromissos from './MelissaCompromissos.vue'; @@ -42,8 +46,9 @@ import MelissaBloqueios from './MelissaBloqueios.vue'; import MelissaAgendador from './MelissaAgendador.vue'; import MelissaAgendaConfig from './MelissaAgendaConfig.vue'; import MelissaPagamento from './MelissaPagamento.vue'; -import MelissaConfigSidebar from './MelissaConfigSidebar.vue'; -import { isMelissaConfigSlug } from './composables/melissaConfigGrupos.js'; +// Sidebar global de configs removido — substituido por botao + popover +// (MelissaConfigPopover) dentro de cada pagina de config. Resolveu lag +// de scroll que o overlay sempre visivel causava em mobile. import MelissaEmbed from './MelissaEmbed.vue'; import MelissaCadastrosRecebidos from './MelissaCadastrosRecebidos.vue'; import MelissaAgendamentosRecebidos from './MelissaAgendamentosRecebidos.vue'; @@ -56,7 +61,7 @@ import MelissaDocumentosTemplates from './MelissaDocumentosTemplates.vue'; import MelissaRelatorios from './MelissaRelatorios.vue'; import MelissaMedicos from './MelissaMedicos.vue'; import MelissaEventoPanel from './MelissaEventoPanel.vue'; -import { TOQUES, playToque } from './melissaToques'; +import { TOQUE_IDS, useMelissaToques } from './composables/useMelissaToques'; import { useMelissaPacientes } from './composables/useMelissaPacientes'; import { useMelissaEventosHoje } from './composables/useMelissaEventos'; import { useMelissaWhatsapp } from './composables/useMelissaWhatsapp'; @@ -250,16 +255,10 @@ const secaoAberta = computed(() => { return null; }); -// Mostra o sidebar lateral de configuracoes (MelissaConfigSidebar) -// quando a rota atual e um slug do catalogo de configs (any item em -// MELISSA_CONFIG_GRUPOS). -const showConfigSidebar = computed(() => isMelissaConfigSlug(secaoAberta.value)); - -// CSS var pras paginas nativas saberem que precisam abrir espaco a -// esquerda pro sidebar (296px = 280 width + 16 gap). -const configAsideLeftStyle = computed(() => ({ - '--m-config-aside-left': showConfigSidebar.value ? '296px' : '6px' -})); +// (Removido) showConfigSidebar / configAsideLeftStyle — o sidebar +// global de configs foi substituido pelo MelissaConfigPopover +// dentro de cada pagina, ancorado no botao "Configuracoes" no +// topo da .xxx-side. Sem mais CSS var de left dinamico nas pages. // Quando o usuário fecha a seção e volta pro resumo, garante que os // dados estão prontos (caso o idle callback ainda não tenha disparado @@ -355,7 +354,6 @@ function pinMeta(slug) { // 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) @@ -410,61 +408,20 @@ const saudacao = computed(() => { }); // ─────────────────────────────────────────────────────────────── -// Background customizável +// Background customizável (composable) // ─────────────────────────────────────────────────────────────── -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 -})); +// Refs (bgUrl/overlayOpacity/bgImageOpacity) sao criados pelo +// composable; persistencia continua aqui no pai (saveLayoutPrefs + +// applyPrefsPayload + watcher) pra agrupar com outras prefs. +const { + bgUrl, + overlayOpacity, + bgImageOpacity, + defaultBgStyle, + photoStyle, + onFileChange, + clearBg +} = useMelissaWallpaper(); // ─────────────────────────────────────────────────────────────── // Tema (dark/light + cor primária) — usa a infra existente do app @@ -524,6 +481,16 @@ function setSurface(name) { userSettings.queuePatch({ surface_color: name }); } +// Preset = engine de tokens do PrimeVue. Mantemos so Lara e Nora +// (Aura ficou de fora — decisao de design pra reduzir ruido visual). +const PRESETS = THEME_PRESETS.filter((p) => p === 'Lara' || p === 'Nora'); +function setPreset(name) { + if (!name || layoutConfig.preset === name) return; + layoutConfig.preset = name; + applyThemeEngine(layoutConfig); + userSettings.queuePatch({ preset: name }); +} + // ─────────────────────────────────────────────────────────────── // Settings popover (canto superior direito) @@ -531,164 +498,12 @@ function setSurface(name) { const settingsOpen = ref(false); // ─────────────────────────────────────────────────────────────── -// Timeline horizontal — range derivado de agenda_regras_semanais -// (regra do dia da semana atual). Fallback: agenda_configuracoes -// global → 08–18h. Range expande pra incluir eventos fora do -// expediente (sessão excepcional não some da timeline). +// Timeline horizontal — range/eco/posicoes/auto-scroll/cursor "Agora" // ─────────────────────────────────────────────────────────────── -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: 02h–23h) 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')}`; -} +// Tudo (HORA_INICIO/FIM, hoursRange, eventosVisiveis, isFolga, todayFeriado, +// tlHScrollEl, tlEcoState, etc.) migrou pro componente MelissaTimelineHoje. +// Pai so passa eventos brutos + workRules/settings/feriados via props, +// e recebe @evento (pra abrir dialog) + @clear-filter (pra limpar tipo). // Contagens por tipo + frase resumo do dia const contagensDia = computed(() => { @@ -912,7 +727,9 @@ function onRemarcar() { updateEventoStatus('remarcar', 'Marcada pra remarcar'); } -// Filtro da timeline por tipo (clicado a partir do resumo) +// Filtro da timeline por tipo (clicado no resumo do hero, lido pela +// timeline e pelo hero pra destacar chip ativo). Permanece aqui no +// pai porque eh state compartilhado entre dois filhos. const filtroTipo = ref(null); function toggleFiltro(tipo) { filtroTipo.value = filtroTipo.value === tipo ? null : tipo; @@ -921,85 +738,14 @@ 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 - }; +// Helper de formatacao de hora — usado pelo card "proximo paciente" +// abaixo. A timeline tem sua propria copia interna. +function fmtHora(h) { + const horas = Math.floor(h); + const mins = Math.round((h - horas) * 60); + return `${String(horas).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; } -// ── 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. // ─────────────────────────────────────────────────────────────── @@ -1123,7 +869,10 @@ const CONFIG_DURACAO_MIN = 50; const cronoRef = ref(null); const cronoVisible = ref(false); // sincronizado via @visible-change -const toqueTermino = ref('sino'); // TODO: persistir nas configs do tenant + +// Preferencia de toque + funcao de teste (composable). cronoRef/abrir/fechar +// continuam aqui — sao instance state do , nao da pref. +const { toqueTermino, testarToque } = useMelissaToques('sino'); function abrirCronometro() { cronoRef.value?.abrir(); @@ -1131,9 +880,6 @@ function abrirCronometro() { 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 @@ -1146,8 +892,10 @@ provide('melissaSettings', { activeSurface, PRIMARY_COLORS, SURFACES, + PRESETS, setPrimary, setSurface, + setPreset, setDark, // bg + opacidades + handler do input bgUrl, @@ -1309,42 +1057,8 @@ onMounted(async () => { 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()); - } -}); +// Auto-scroll inicial + ResizeObserver da timeline migrou pro +// componente MelissaTimelineHoje (agora self-contained). // ─────────────────────────────────────────────────────────────── // Workspace overlay @@ -1401,7 +1115,7 @@ function onKeydown(e) {