Melissa Agenda: paridade com AgendaTerapeuta + responsivo mobile
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 <lg: aside + widgets viram drawer off-canvas (slide esquerda); calendar fullwidth; "Ações" menu mobile concentra timeMode/onlySessions/ bloquear; backdrop com click-outside MelissaEventoPanel (B3 estático-revisado): - Substitui panel inline que crashava em campos inexistentes - Action bar agrupada (status / paciente / geral) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="win11-root" :class="{ 'win11-has-photo': !!bgUrl }" :style="bgStyle">
|
||||
<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})` }" />
|
||||
|
||||
@@ -489,12 +758,15 @@ function onKeydown(e) {
|
||||
<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-2 flex items-center gap-2 text-sm"
|
||||
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"
|
||||
@@ -505,6 +777,20 @@ function onKeydown(e) {
|
||||
</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) }}%
|
||||
@@ -515,7 +801,7 @@ function onKeydown(e) {
|
||||
min="0"
|
||||
max="0.8"
|
||||
step="0.05"
|
||||
class="w-full accent-white"
|
||||
class="settings-range w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -538,11 +824,14 @@ function onKeydown(e) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-white/80">Formato 24h</span>
|
||||
<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"
|
||||
:class="use24h ? 'bg-emerald-500' : 'bg-white/20'"
|
||||
class="w-10 h-6 rounded-full transition-colors relative settings-toggle"
|
||||
:class="use24h ? 'is-on' : 'bg-white/20'"
|
||||
@click="use24h = !use24h"
|
||||
>
|
||||
<span
|
||||
@@ -551,6 +840,36 @@ function onKeydown(e) {
|
||||
/>
|
||||
</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>
|
||||
@@ -847,96 +1166,20 @@ function onKeydown(e) {
|
||||
<!-- DIALOG — Evento da timeline (sessão / supervisão / reunião) -->
|
||||
<!-- ═══════════════════════════════════════════════════════ -->
|
||||
<Transition name="lift">
|
||||
<div
|
||||
<MelissaEventoPanel
|
||||
v-if="eventoSelecionado"
|
||||
class="evento-layer"
|
||||
@click.self="fecharEvento"
|
||||
>
|
||||
<div class="evento-panel">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between mb-5">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="evento-pill" :style="{ backgroundColor: eventoSelecionado.color }" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-white/55 text-xs uppercase tracking-[0.2em]">
|
||||
{{ tipoLabel(eventoSelecionado.tipo) }}
|
||||
</div>
|
||||
<div class="text-white text-lg font-light mt-1 truncate">
|
||||
{{ eventoSelecionado.tipo === 'sessao'
|
||||
? eventoSelecionado.pacienteNome
|
||||
: eventoSelecionado.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="glass-btn w-9 h-9 grid place-items-center shrink-0"
|
||||
title="Fechar (Esc)"
|
||||
@click="fecharEvento"
|
||||
>
|
||||
<i class="pi pi-times text-white/90 text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="flex flex-col gap-3 mb-6">
|
||||
<div class="evento-row">
|
||||
<i class="pi pi-clock" />
|
||||
<span>{{ fmtHora(eventoSelecionado.startH) }} – {{ fmtHora(eventoSelecionado.endH) }}</span>
|
||||
</div>
|
||||
|
||||
<template v-if="eventoSelecionado.tipo === 'sessao'">
|
||||
<div class="evento-row">
|
||||
<i class="pi pi-map-marker" />
|
||||
<span>{{ eventoSelecionado.modalidade }}</span>
|
||||
</div>
|
||||
<div class="evento-row">
|
||||
<i class="pi pi-dollar" />
|
||||
<span>R$ {{ eventoSelecionado.valor.toFixed(2).replace('.', ',') }}</span>
|
||||
</div>
|
||||
<div class="evento-row">
|
||||
<i class="pi pi-info-circle" />
|
||||
<span>{{ eventoSelecionado.status }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="eventoSelecionado.tipo === 'reuniao'">
|
||||
<div class="evento-row">
|
||||
<i class="pi pi-map-marker" />
|
||||
<span>{{ eventoSelecionado.local }}</span>
|
||||
</div>
|
||||
<div class="evento-row">
|
||||
<i class="pi pi-users" />
|
||||
<span>{{ eventoSelecionado.participantes }} participantes</span>
|
||||
</div>
|
||||
<div class="evento-desc">{{ eventoSelecionado.descricao }}</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="eventoSelecionado.tipo === 'supervisao'">
|
||||
<div class="evento-row">
|
||||
<i class="pi pi-user" />
|
||||
<span>{{ eventoSelecionado.supervisorNome }}</span>
|
||||
</div>
|
||||
<div class="evento-row">
|
||||
<i class="pi pi-map-marker" />
|
||||
<span>{{ eventoSelecionado.local }}</span>
|
||||
</div>
|
||||
<div class="evento-desc">{{ eventoSelecionado.descricao }}</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Ação principal -->
|
||||
<button class="evento-action" @click="fecharEvento">
|
||||
<template v-if="eventoSelecionado.tipo === 'sessao'">
|
||||
<i class="pi pi-file-edit text-xs" />
|
||||
Abrir prontuário
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="pi pi-info-circle text-xs" />
|
||||
Ver detalhes
|
||||
</template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
:evento="eventoSelecionado"
|
||||
:busy="eventoBusy"
|
||||
@close="fecharEvento"
|
||||
@concluir="onConcluir"
|
||||
@faltou="onFaltou"
|
||||
@cancelar="onCancelar"
|
||||
@remarcar="onRemarcar"
|
||||
@edit="onEditEvento"
|
||||
@abrir-prontuario="onAbrirProntuario"
|
||||
@whatsapp="onWhatsapp"
|
||||
@historico="onHistoricoSessoes"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════ -->
|
||||
@@ -1012,6 +1255,7 @@ function onKeydown(e) {
|
||||
<Transition name="page-fade">
|
||||
<MelissaAgenda
|
||||
v-if="secaoAberta === 'agenda'"
|
||||
ref="melissaAgendaRef"
|
||||
:pacientes="pacientesReais"
|
||||
@select-evento="abrirEvento"
|
||||
@close="fecharSecao"
|
||||
@@ -1019,13 +1263,22 @@ function onKeydown(e) {
|
||||
/>
|
||||
</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 -->
|
||||
<!-- (Pacientes, WhatsApp, Financeiro, Copilot...) -->
|
||||
<!-- (WhatsApp, Financeiro, Copilot...) -->
|
||||
<!-- ═══════════════════════════════════════════════════════ -->
|
||||
<Transition name="lift">
|
||||
<div
|
||||
v-if="secaoAberta && secaoAberta !== 'agenda'"
|
||||
v-if="secaoAberta && secaoAberta !== 'agenda' && secaoAberta !== 'pacientes'"
|
||||
class="secao-layer"
|
||||
@click.self="fecharSecao"
|
||||
>
|
||||
@@ -1078,6 +1331,65 @@ function onKeydown(e) {
|
||||
: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>
|
||||
|
||||
@@ -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 <script> referencia essas vars; mudar aqui muda o bloom todo. */
|
||||
--bloom-c1: var(--p-primary-400);
|
||||
--bloom-c2: var(--p-primary-300);
|
||||
|
||||
Reference in New Issue
Block a user