From ffcb8b17f9853ad0ef94cf9d4d88633a298a1fb8 Mon Sep 17 00:00:00 2001 From: Leonardo Date: Mon, 27 Apr 2026 18:15:56 -0300 Subject: [PATCH] Melissa Agenda: paridade com AgendaTerapeuta + responsivo mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composable useMelissaAgenda (~1150 linhas, exclusivo Melissa): - Orquestra useAgendaEvents + useRecurrence + useDeterminedCommitments + useFeriados + useCommitmentServices - 7 cases de save (avulso, recorrente C, somente_este D, este_e_seguintes E, todos F, todos_sem_excecao G + tratamento de exclusion constraint) - 3 cases de delete (somente_este, este_e_seguintes, todos com encerrar série) - onCreateEvento (botão Agendar), onSelectTime com cap de 120min, persistMoveOrResize com confirm dialog descritivo e bold em datas/horas - Bloqueio: openBloqueioDialog(mode) com 4 modos MelissaLayout: - Provide composable via MELISSA_AGENDA_KEY (inject em MelissaAgenda) - Renderiza AgendaEventDialog + BloqueioDialog + ConfirmDialog - Slot #message v-html pra renderizar HTML em messages do confirm - onEditEvento liga panel ao dialog completo (B3 não-stub) MelissaAgenda: - Drop useMelissaEventosRange — eventos vêm do composable injetado - Drag/resize/select-to-create habilitados quando há composable - Cluster Paciente + Agendar (50/50 primary) - Toolbar: timeMode (24/12/Meu) + onlySessions + bloquear-menu (desktop) - Header: Pacientes (mobile-only, abre drawer) + Configurações + Fechar - Mobile --- src/layout/melissa/MelissaAgenda.vue | 556 ++++++- src/layout/melissa/MelissaEventoPanel.vue | 436 ++++++ src/layout/melissa/MelissaLayout.vue | 724 ++++++--- .../melissa/composables/useMelissaAgenda.js | 1303 +++++++++++++++++ .../melissa/composables/useMelissaEventos.js | 3 +- 5 files changed, 2746 insertions(+), 276 deletions(-) create mode 100644 src/layout/melissa/MelissaEventoPanel.vue create mode 100644 src/layout/melissa/composables/useMelissaAgenda.js diff --git a/src/layout/melissa/MelissaAgenda.vue b/src/layout/melissa/MelissaAgenda.vue index 31ce1b1..a1d73b9 100644 --- a/src/layout/melissa/MelissaAgenda.vue +++ b/src/layout/melissa/MelissaAgenda.vue @@ -14,7 +14,8 @@ * Mock visual (sem FullCalendar real). Quando integrar de verdade, * trocar a área central pelo FullCalendar com tema glass. */ -import { ref, computed, onMounted, watch } from 'vue'; +import { ref, computed, onMounted, onBeforeUnmount, watch, inject } from 'vue'; +import { useRouter } from 'vue-router'; import FullCalendar from '@fullcalendar/vue3'; import timeGridPlugin from '@fullcalendar/timegrid'; import dayGridPlugin from '@fullcalendar/daygrid'; @@ -22,6 +23,7 @@ import listPlugin from '@fullcalendar/list'; import interactionPlugin from '@fullcalendar/interaction'; import ptBrLocale from '@fullcalendar/core/locales/pt-br'; import { useMelissaEventosRange } from './composables/useMelissaEventos'; +import { MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda'; import { useFeriados } from '@/composables/useFeriados'; import { useTenantStore } from '@/stores/tenantStore'; import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings'; @@ -45,11 +47,86 @@ const props = defineProps({ const emit = defineEmits(['select-evento', 'close', 'patient-created']); // ── Estado ───────────────────────────────────────────────────── +const router = useRouter(); const busca = ref(''); const pacienteSelecionadoId = ref(null); const calendarView = ref('semana'); // 'dia' | 'semana' | 'mes' | 'lista' const refDate = ref(new Date()); // data de referência (sincronizada com o FC via datesSet) +// ── Filtros adicionais (espelham AgendaTerapeutaPage) ───────── +// timeMode: '24' (00–24h) | '12' (06–18h) | 'my' (intervalo da workRules) +const timeMode = ref('my'); +const timeModeOptions = [ + { label: '24h', value: '24' }, + { label: '12h', value: '12' }, + { label: 'Meu', value: 'my' } +]; +// onlySessions: true filtra eventos não-paciente do FC + lista +const onlySessions = ref(false); +const onlySessionsOptions = [ + { label: 'Apenas Sessões', value: true }, + { label: 'Tudo', value: false } +]; + +// ── Drawer mobile ──────────────────────────────────────────── +// Quando largura <1024px, .ma-side e .ma-widgets viram off-canvas +// dentro de .ma-drawer. drawerOpen controla translateX via CSS. +const drawerOpen = ref(false); +const isMobile = ref(false); + +let _mqMobile = null; +function _onMqChange(e) { + isMobile.value = e.matches; + // Cruzou pra desktop → fecha o drawer pra evitar layout zoado + if (!e.matches) drawerOpen.value = false; +} +onMounted(() => { + if (typeof window !== 'undefined' && window.matchMedia) { + _mqMobile = window.matchMedia('(max-width: 1023px)'); + isMobile.value = _mqMobile.matches; + _mqMobile.addEventListener('change', _onMqChange); + } +}); +onBeforeUnmount(() => { + if (_mqMobile) _mqMobile.removeEventListener('change', _onMqChange); +}); + +function toggleDrawer() { + drawerOpen.value = !drawerOpen.value; +} +function fecharDrawer() { + drawerOpen.value = false; +} + +// ── Configurações da agenda (botão no header) ───────────────── +function goSettings() { + router.push('/configuracoes/agenda'); +} + +// ── Menu mobile "Ações" (popover) ───────────────────────────── +// Concentra timeMode/onlySessions/bloquear em um único trigger mobile +// pra não inflar a toolbar em telas pequenas. +const mobileActionsRef = ref(null); +const mobileActionsItems = computed(() => [ + { + label: onlySessions.value ? 'Mostrar tudo' : 'Apenas sessões', + icon: onlySessions.value ? 'pi pi-list' : 'pi pi-filter', + command: () => { onlySessions.value = !onlySessions.value; } + }, + { separator: true }, + { label: 'Horário 24h', icon: timeMode.value === '24' ? 'pi pi-check' : 'pi pi-clock', command: () => { timeMode.value = '24'; } }, + { label: 'Horário 12h', icon: timeMode.value === '12' ? 'pi pi-check' : 'pi pi-clock', command: () => { timeMode.value = '12'; } }, + { label: 'Meu horário', icon: timeMode.value === 'my' ? 'pi pi-check' : 'pi pi-clock', command: () => { timeMode.value = 'my'; } }, + { separator: true }, + { label: 'Bloquear por horário', icon: 'pi pi-clock', command: () => M?.openBloqueioDialog?.('horario') }, + { label: 'Bloquear por período', icon: 'pi pi-calendar-clock', command: () => M?.openBloqueioDialog?.('periodo') }, + { label: 'Bloquear por dia', icon: 'pi pi-calendar-times', command: () => M?.openBloqueioDialog?.('dia') }, + { label: 'Bloqueio por feriados', icon: 'pi pi-star', command: () => M?.openBloqueioDialog?.('feriados') } +]); +function openMobileActions(event) { + mobileActionsRef.value?.toggle(event); +} + // ── Pacientes filtrados + ordenação (novos no topo) ────────── // Pacientes criados nas últimas NOVO_THRESHOLD_DIAS ficam no topo da lista, // destacados com bg primary suave. Após esse período voltam pra ordem @@ -125,11 +202,16 @@ const refDateIsToday = computed(() => { return hoje >= inicio && hoje < fim; }); -// Range visível atual (passa pro composable que refetcha) -const viewStart = ref(new Date()); -const viewEnd = ref(new Date()); +// Composable injetado pelo MelissaLayout — fonte única de eventos do FC +// (real + ocorrências virtuais via useRecurrence). viewStart/End vivem no +// composable; mutamos elas no datesSet do FC pra disparar o refetch lá. +// +// Fallback: se MelissaAgenda for usado fora do MelissaLayout (preview +// standalone), cai pra refs locais e composable legado useMelissaEventosRange. +const M = inject(MELISSA_AGENDA_KEY, null); -// Inicializa range de uma semana ao redor de hoje (FC vai sobrescrever no datesSet de mount) +const _viewStartLocal = ref(new Date()); +const _viewEndLocal = ref(new Date()); { const hoje = new Date(); const dow = hoje.getDay(); @@ -139,12 +221,19 @@ const viewEnd = ref(new Date()); segunda.setHours(0, 0, 0, 0); const domingoNext = new Date(segunda); domingoNext.setDate(segunda.getDate() + 7); - viewStart.value = segunda; - viewEnd.value = domingoNext; + _viewStartLocal.value = segunda; + _viewEndLocal.value = domingoNext; refDate.value = hoje; } -const { eventos: eventosSemana, refetch: refetchEventosFc } = useMelissaEventosRange(viewStart, viewEnd); +const viewStart = M?.viewStart || _viewStartLocal; +const viewEnd = M?.viewEnd || _viewEndLocal; + +// Quando há M (caso normal), eventos vêm dele. Sem M (standalone), cai pro +// composable legado que SÓ traz eventos reais (sem recorrência virtual). +const _legacyRange = M ? null : useMelissaEventosRange(viewStart, viewEnd); +const eventosSemana = M?.eventos ?? _legacyRange?.eventos ?? ref([]); +const refetchEventosFc = M?.refetch ?? _legacyRange?.refetch ?? (() => {}); // Mapa eventos.dateKey → array (pra widgets de stats e sessões hoje) const eventosPorDia = computed(() => { @@ -186,21 +275,67 @@ function statLabel(key) { // Pattern espelha AgendaTerapeutaPage:601. const fcEvents = computed(() => { const pred = statFiltroAtivo.value ? STAT_FILTERS[statFiltroAtivo.value] : () => true; + // onlySessions filtra eventos sem paciente vinculado (compromissos + // pessoais como "Análise" ainda usam tipo='sessao' no banco — patient_id + // é o discriminador real). Espelha AgendaTerapeutaPage:446. + const sessionPred = onlySessions.value + ? (ev) => !!(ev.patient_id || ev.paciente_id) + : () => true; return [ - ...eventosSemana.value.filter(pred).map((ev) => ({ - id: ev.id, - title: ev.label, - start: ev.inicio_em, - end: ev.fim_em, - backgroundColor: `${ev.color}26`, // ~15% opacity - borderColor: ev.color, - textColor: 'white', - extendedProps: ev - })), + ...eventosSemana.value + .filter(pred) + .filter(sessionPred) + .map((ev) => ({ + id: ev.id, + title: ev.label, + start: ev.inicio_em, + end: ev.fim_em, + backgroundColor: `${ev.color}26`, // ~15% opacity + borderColor: ev.color, + textColor: 'white', + extendedProps: ev + })), ...feriadoFcEvents.value ]; }); +// ── slotMinTime / slotMaxTime baseado em timeMode ───────────── +// 24: 00–24h. 12: 06–18h. my: range das workRules (snap em 30min). +function _hhmmToMin(t) { + const [h, m] = String(t || '00:00').split(':').map(Number); + return (h || 0) * 60 + (m || 0); +} +function _floorTo30(hhmmss) { + const m = _hhmmToMin(String(hhmmss).slice(0, 5)); + const f = m - (m % 30); + return `${String(Math.floor(f / 60)).padStart(2, '0')}:${String(f % 60).padStart(2, '0')}:00`; +} +function _ceilTo30(hhmmss) { + const m = _hhmmToMin(String(hhmmss).slice(0, 5)); + const c = m % 30 === 0 ? m : m + (30 - (m % 30)); + return `${String(Math.floor(c / 60)).padStart(2, '0')}:${String(c % 60).padStart(2, '0')}:00`; +} +function _myHoursRange() { + const rules = (workRules.value || []).filter((r) => r.ativo !== false); + if (!rules.length) return { start: '08:00:00', end: '20:00:00' }; + const starts = rules.map((r) => _floorTo30(r.hora_inicio)); + const ends = rules.map((r) => _ceilTo30(r.hora_fim)); + return { + start: starts.reduce((a, b) => (a < b ? a : b)), + end: ends.reduce((a, b) => (a > b ? a : b)) + }; +} +const slotMinTime = computed(() => { + if (timeMode.value === '24') return '00:00:00'; + if (timeMode.value === '12') return '06:00:00'; + return _myHoursRange().start; +}); +const slotMaxTime = computed(() => { + if (timeMode.value === '24') return '24:00:00'; + if (timeMode.value === '12') return '18:00:00'; + return _myHoursRange().end; +}); + function escHtml(s) { return String(s ?? '') .replace(/&/g, '&') @@ -226,8 +361,11 @@ const fcOptions = computed(() => ({ initialView: VIEW_MAP[calendarView.value] || 'timeGridWeek', initialDate: refDate.value, nowIndicator: true, - editable: false, // preview: sem drag/resize ainda - selectable: false, // preview: sem click-drag pra criar + // Drag/resize/select habilitam apenas com M (composable disponível) — + // standalone (sem M) fica readonly por compat (preview puro). + editable: !!M, + selectable: !!M, + selectMirror: true, weekends: true, // Header da semana ("seg 20/04") vira link clicável → vai pra view dia. // Default do FC mandaria pra dayGridDay; forçamos timeGridDay (nossa "dia"). @@ -239,8 +377,8 @@ const fcOptions = computed(() => ({ allDaySlot: false, // tira a faixa "Dia inteiro" — recupera altura // Slots de 30min com label em CADA linha (08:00, :30, 09:00, :30...) // Snap continua em 15min pra futura criação/drag de eventos. - slotMinTime: '08:00:00', - slotMaxTime: '20:00:00', + slotMinTime: slotMinTime.value, + slotMaxTime: slotMaxTime.value, slotDuration: '00:30:00', snapDuration: '00:15:00', slotLabelInterval: '00:30:00', @@ -261,6 +399,23 @@ const fcOptions = computed(() => ({ const ev = info.event.extendedProps; if (ev) emit('select-evento', ev); }, + // Drag → reagenda evento (mesmo dia, hora diferente OU outro dia) + eventDrop: (info) => { + if (!M) { info.revert?.(); return; } + M.persistMoveOrResize(info, 'Sessão movida'); + }, + // Resize → muda duração da sessão + eventResize: (info) => { + if (!M) { info.revert?.(); return; } + M.persistMoveOrResize(info, 'Duração alterada'); + }, + // Click-drag em área vazia → abre dialog pra criar evento novo, com + // start/end pré-preenchidos. AgendaEventDialog cuida do resto (seleção + // de paciente, modalidade, recorrência). + select: (info) => { + if (!M) return; + M.onSelectTime({ start: info.start, end: info.end }); + }, eventContent: (arg) => { const ext = arg.event.extendedProps || {}; const titulo = arg.event.title || '—'; @@ -291,6 +446,20 @@ function setView(v) { fcApi()?.changeView(VIEW_MAP[v]); } +// ── Menu de Bloqueio (toolbar) ───────────────────────────────── +// Espelha o blockMenuItems da AgendaTerapeutaPage. Cada modo abre o +// BloqueioDialog renderizado no MelissaLayout (via composable M). +const bloqueioMenuRef = ref(null); +const bloqueioMenuItems = computed(() => [ + { label: 'Bloquear por horário', icon: 'pi pi-clock', command: () => M?.openBloqueioDialog?.('horario') }, + { label: 'Bloquear por período', icon: 'pi pi-calendar-clock', command: () => M?.openBloqueioDialog?.('periodo') }, + { label: 'Bloquear por dia', icon: 'pi pi-calendar-times', command: () => M?.openBloqueioDialog?.('dia') }, + { label: 'Bloqueio por feriados', icon: 'pi pi-star', command: () => M?.openBloqueioDialog?.('feriados') } +]); +function openBloqueioMenu(event) { + bloqueioMenuRef.value?.toggle(event); +} + function fmtHora(h) { const horas = Math.floor(h); const mins = Math.round((h - horas) * 60); @@ -632,6 +801,20 @@ const kebabItems = computed(() => { { label: 'Editar', icon: 'pi pi-pencil', command: () => { editPatientId.value = String(p.id); cadastroFullDialog.value = true; } } ]; }); + +// ── API exposta pro parent (MelissaLayout) ──────────────────── +// MelissaEventoPanel emite ações que o parent precisa orquestrar com +// a Agenda — aqui ficam os métodos invocáveis via ref. +function openProntuario(patient) { + if (!patient) return; + prontuarioPatient.value = { ...patient }; + prontuarioOpen.value = true; +} +defineExpose({ + refetch: refetchEventosFc, + openProntuario, + setView +}); + + diff --git a/src/layout/melissa/MelissaLayout.vue b/src/layout/melissa/MelissaLayout.vue index 3b48926..eb8f3c0 100644 --- a/src/layout/melissa/MelissaLayout.vue +++ b/src/layout/melissa/MelissaLayout.vue @@ -14,15 +14,27 @@ * * Rota atual (sandbox): /preview/melissa */ -import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'; +import { ref, computed, watch, onMounted, onBeforeUnmount, provide } from 'vue'; +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(); @@ -142,15 +154,35 @@ const saudacao = computed(() => { // Background customizável // ─────────────────────────────────────────────────────────────── const bgUrl = ref(''); // vazio = usa gradiente default -const overlayOpacity = ref(0.35); // 0–0.8 +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); @@ -159,23 +191,68 @@ function clearBg() { bgUrl.value = ''; } -const bgStyle = computed(() => { - if (bgUrl.value) { - return { - backgroundImage: `url(${bgUrl.value})`, - backgroundSize: 'cover', - backgroundPosition: 'center' - }; - } - // Default: gradiente "Bloom"-ish (Win11) — cores vêm de CSS vars que flipam - // automaticamente com dark/light AND seguem o preset escolhido (ver style - // global no fim do arquivo: --bloom-c1/c2/base-1/base-2). - return { - 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' - }; -}); +// 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) @@ -202,11 +279,6 @@ function fmtHora(h) { return `${String(horas).padStart(2, '0')}:${String(mins).padStart(2, '0')}`; } -function tipoLabel(tipo) { - const map = { sessao: 'Atendimento', supervisao: 'Supervisão', reuniao: 'Reunião' }; - return map[tipo] || tipo; -} - // Contagens por tipo + frase resumo do dia const contagensDia = computed(() => { const c = { sessao: 0, supervisao: 0, reuniao: 0 }; @@ -230,11 +302,124 @@ const resumoPartes = computed(() => { // 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) @@ -351,18 +536,59 @@ function testarToque() { } // ─────────────────────────────────────────────────────────────── -// Persistência de prefs UI (localStorage) -// Só sobrevive a refresh; vai migrar pras configs do tenant depois. +// Persistência de prefs UI // ─────────────────────────────────────────────────────────────── -function saveLayoutPrefs() { - const prefs = { +// 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, - bgUrl: bgUrl.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 { @@ -374,7 +600,7 @@ function saveLayoutPrefs() { } } -function loadLayoutPrefs() { +function loadLocalPrefs() { let raw; try { raw = localStorage.getItem(LAYOUT_STORAGE_KEY); } catch { return; } if (!raw) return; @@ -383,40 +609,78 @@ function loadLayoutPrefs() { try { prefs = JSON.parse(raw); } catch { return; } if (!prefs || typeof prefs !== 'object') return; - // Sanitização — cada campo é input externo, não confiar - 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; - } - if (typeof prefs.use24h === 'boolean') { - use24h.value = prefs.use24h; - } + 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; } - // cardsAtivos: filtra ids inválidos (catálogo pode ter mudado entre versões) - 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; +} + +// ── 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; } - if (prefs.cardsLayout === 'linha-unica' || prefs.cardsLayout === 'duas-linhas') { - cardsLayout.value = prefs.cardsLayout; +} + +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, use24h, bgUrl, cardsAtivos, cardsLayout], - saveLayoutPrefs, + [toqueTermino, overlayOpacity, bgImageOpacity, use24h, bgUrl, cardsAtivos, cardsLayout], + () => { + saveLayoutPrefs(); + queueDbSave(); + }, { deep: true } ); // Carrega antes do paint (use onMounted; o flash inicial é aceitável) -onMounted(loadLayoutPrefs); +onMounted(async () => { + loadLocalPrefs(); // sync: paint imediato com valores cached + await loadDbPrefs(); // async: sobrescreve com valores autoritativos do DB +}); // ─────────────────────────────────────────────────────────────── // Workspace overlay @@ -463,7 +727,12 @@ function onKeydown(e) { @@ -1089,6 +1401,17 @@ function onKeydown(e) { 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; @@ -1411,6 +1734,40 @@ function onKeydown(e) { 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; @@ -1480,73 +1837,8 @@ function onKeydown(e) { margin-bottom: 1.5rem; } -/* ─── Dialog de evento da timeline ─────────────────────────── */ -.evento-layer { - position: absolute; - inset: 0; - z-index: 55; - display: flex; - align-items: center; - justify-content: center; - padding: 2rem; -} -.evento-panel { - width: min(420px, 100%); - 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); -} -.evento-pill { - width: 4px; - height: 40px; - border-radius: 2px; - flex-shrink: 0; -} -.evento-row { - display: flex; - align-items: center; - gap: 12px; - font-size: 0.85rem; - color: var(--m-text); -} -.evento-row i { - width: 16px; - text-align: center; - color: var(--m-text-muted); - font-size: 0.85rem; -} -.evento-desc { - color: var(--m-text-muted); - font-size: 0.85rem; - line-height: 1.5; - padding-top: 12px; - margin-top: 4px; - border-top: 1px solid var(--m-border); -} -.evento-action { - width: 100%; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 12px 14px; - background: var(--m-bg-soft-hover); - border: 1px solid var(--m-border-strong); - color: white; - border-radius: 12px; - font-size: 0.9rem; - font-weight: 500; - cursor: pointer; - transition: background-color 140ms ease, border-color 140ms ease; -} -.evento-action:hover { - background: var(--m-border-strong); - border-color: var(--m-border-strong); -} +/* (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, @@ -1798,7 +2090,7 @@ function onKeydown(e) { /* ─── 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 computed bgStyle + pra tons claros (200/100 + surface-0/primary-50). O defaultBgStyle no