Correcao Sidebar Classico e Rail, Correcao Layout, Ajuste de Breakpoint para Tailwind, Ajuste AppTopbar, Ajuste Menu PopOver, Recriado Paleta de Cores, Inserido algumas animações leves, Reajuste Cor items NOVOS da tabela, Drawer Ajuda Corrigido no Logout, Whatsapp, sms, email, recursos extras

This commit is contained in:
Leonardo
2026-03-24 21:26:58 -03:00
parent a89d1f5560
commit 53a4980396
453 changed files with 121427 additions and 174407 deletions
+138 -160
View File
@@ -15,211 +15,189 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, inject } from 'vue'
import { useLayout } from '@/layout/composables/layout'
import { primaryColors, surfaces, presetOptions, applyThemeEngine } from '@/theme/theme.options'
import { computed, inject } from 'vue';
import { useLayout } from '@/layout/composables/layout';
import { primaryColors, surfaces, presetOptions, applyThemeEngine } from '@/theme/theme.options';
const { layoutConfig, isDarkTheme, changeMenuMode, setVariant } = useLayout()
const { layoutConfig, isDarkTheme, changeMenuMode, setVariant } = useLayout();
// ✅ vem do AppTopbar (mesma instância)
const queuePatch = inject('queueUserSettingsPatch', null)
const queuePatch = inject('queueUserSettingsPatch', null);
// menu mode options
const menuModeOptions = [
{ label: 'Static', value: 'static' },
{ label: 'Overlay', value: 'overlay' }
]
{ label: 'Static', value: 'static' },
{ label: 'Overlay', value: 'overlay' }
];
// ✅ v-model sincronizado (sem state local)
const presetModel = computed({
get: () => layoutConfig.preset,
set: (val) => {
if (!val || val === layoutConfig.preset) return
layoutConfig.preset = val
get: () => layoutConfig.preset,
set: (val) => {
if (!val || val === layoutConfig.preset) return;
layoutConfig.preset = val;
applyThemeEngine(layoutConfig)
queuePatch?.({ preset: val })
}
})
applyThemeEngine(layoutConfig);
queuePatch?.({ preset: val });
}
});
const menuModeModel = computed({
get: () => layoutConfig.menuMode,
set: (val) => {
if (!val || val === layoutConfig.menuMode) return
layoutConfig.menuMode = val
get: () => layoutConfig.menuMode,
set: (val) => {
if (!val || val === layoutConfig.menuMode) return;
layoutConfig.menuMode = val;
// ✅ changeMenuMode espera event.value (seu composable usa event.value)
try { changeMenuMode({ value: val }) } catch {}
// ✅ changeMenuMode espera event.value (seu composable usa event.value)
try {
changeMenuMode({ value: val });
} catch {}
queuePatch?.({ menu_mode: val })
}
})
queuePatch?.({ menu_mode: val });
}
});
function updateColors (type, item) {
if (type === 'primary') {
layoutConfig.primary = item.name
applyThemeEngine(layoutConfig)
queuePatch?.({ primary_color: item.name })
return
}
function updateColors(type, item) {
if (type === 'primary') {
layoutConfig.primary = item.name;
applyThemeEngine(layoutConfig);
queuePatch?.({ primary_color: item.name });
return;
}
if (type === 'surface') {
layoutConfig.surface = item.name
applyThemeEngine(layoutConfig)
queuePatch?.({ surface_color: item.name })
}
if (type === 'surface') {
layoutConfig.surface = item.name;
applyThemeEngine(layoutConfig);
queuePatch?.({ surface_color: item.name });
}
}
</script>
<template>
<div
class="config-panel hidden absolute top-[3.25rem] right-0 w-64 p-4 bg-surface-0 dark:bg-surface-900 border border-surface rounded-border origin-top shadow-[0px_3px_5px_rgba(0,0,0,0.02),0px_0px_2px_rgba(0,0,0,0.05),0px_1px_4px_rgba(0,0,0,0.08)]"
>
<div class="flex flex-col gap-4">
<div>
<span class="text-sm text-muted-color font-semibold">Primary</span>
<div class="pt-2 flex gap-2 flex-wrap justify-between">
<button
v-for="c of primaryColors"
:key="c.name"
type="button"
:title="c.name"
@click="updateColors('primary', c)"
:class="[
'border-none w-5 h-5 rounded-full p-0 cursor-pointer outline-none outline-offset-1',
{ 'outline-primary': layoutConfig.primary === c.name }
]"
:style="{ backgroundColor: `${c.name === 'noir' ? 'var(--text-color)' : c.palette['500']}` }"
/>
<div
class="config-panel hidden absolute top-[3.25rem] right-0 w-64 p-4 bg-surface-0 dark:bg-surface-900 border border-surface rounded-border origin-top shadow-[0px_3px_5px_rgba(0,0,0,0.02),0px_0px_2px_rgba(0,0,0,0.05),0px_1px_4px_rgba(0,0,0,0.08)]"
>
<div class="flex flex-col gap-4">
<div>
<span class="text-sm text-muted-color font-semibold">Primary</span>
<div class="pt-2 flex gap-2 flex-wrap justify-between">
<button
v-for="c of primaryColors"
:key="c.name"
type="button"
:title="c.name"
@click="updateColors('primary', c)"
:class="['border-none w-5 h-5 rounded-full p-0 cursor-pointer outline-none outline-offset-1', { 'outline-primary': layoutConfig.primary === c.name }]"
:style="{ backgroundColor: `${c.name === 'noir' ? 'var(--text-color)' : c.palette['500']}` }"
/>
</div>
</div>
<div>
<span class="text-sm text-muted-color font-semibold">Surface</span>
<div class="pt-2 flex gap-2 flex-wrap justify-between">
<button
v-for="s of surfaces"
:key="s.name"
type="button"
:title="s.name"
@click="updateColors('surface', s)"
:class="[
'border-none w-5 h-5 rounded-full p-0 cursor-pointer outline-none outline-offset-1',
{ 'outline-primary': layoutConfig.surface ? layoutConfig.surface === s.name : isDarkTheme ? s.name === 'zinc' : s.name === 'slate' }
]"
:style="{ backgroundColor: `${s.palette['500']}` }"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm text-muted-color font-semibold">Presets</span>
<SelectButton v-model="presetModel" :options="presetOptions" :allowEmpty="false" />
</div>
<!-- Menu Mode: visível apenas no Layout Clássico -->
<div v-show="layoutConfig.variant === 'classic'" class="flex flex-col gap-2">
<span class="text-sm text-muted-color font-semibold">Menu Mode</span>
<SelectButton v-model="menuModeModel" :options="menuModeOptions" :allowEmpty="false" optionLabel="label" optionValue="value" />
</div>
<div class="flex flex-col gap-2">
<span class="text-sm text-muted-color font-semibold">Layout</span>
<div class="flex flex-col gap-1">
<!-- Layout Rail -->
<button type="button" class="layout-option" :class="{ 'layout-option--active': layoutConfig.variant === 'rail' }" @click="setVariant('rail')">
<i :class="layoutConfig.variant === 'rail' ? 'pi pi-check-circle' : 'pi pi-circle'" class="layout-option__icon" />
<span class="layout-option__label">Layout Rail</span>
<span v-if="layoutConfig.variant === 'rail'" class="layout-option__badge layout-option__badge--default">Padrão</span>
</button>
<!-- Layout Clássico -->
<button type="button" class="layout-option" :class="{ 'layout-option--active': layoutConfig.variant === 'classic' }" @click="setVariant('classic')">
<i :class="layoutConfig.variant === 'classic' ? 'pi pi-check-circle' : 'pi pi-circle'" class="layout-option__icon" />
<span class="layout-option__label">Layout Clássico</span>
</button>
</div>
</div>
</div>
</div>
<div>
<span class="text-sm text-muted-color font-semibold">Surface</span>
<div class="pt-2 flex gap-2 flex-wrap justify-between">
<button
v-for="s of surfaces"
:key="s.name"
type="button"
:title="s.name"
@click="updateColors('surface', s)"
:class="[
'border-none w-5 h-5 rounded-full p-0 cursor-pointer outline-none outline-offset-1',
{ 'outline-primary': layoutConfig.surface ? layoutConfig.surface === s.name : (isDarkTheme ? s.name === 'zinc' : s.name === 'slate') }
]"
:style="{ backgroundColor: `${s.palette['500']}` }"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm text-muted-color font-semibold">Presets</span>
<SelectButton v-model="presetModel" :options="presetOptions" :allowEmpty="false" />
</div>
<!-- Menu Mode: visível apenas no Layout Clássico -->
<div v-show="layoutConfig.variant === 'classic'" class="flex flex-col gap-2">
<span class="text-sm text-muted-color font-semibold">Menu Mode</span>
<SelectButton
v-model="menuModeModel"
:options="menuModeOptions"
:allowEmpty="false"
optionLabel="label"
optionValue="value"
/>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm text-muted-color font-semibold">Layout</span>
<div class="flex flex-col gap-1">
<!-- Layout Rail -->
<button
type="button"
class="layout-option"
:class="{ 'layout-option--active': layoutConfig.variant === 'rail' }"
@click="setVariant('rail')"
>
<i
:class="layoutConfig.variant === 'rail' ? 'pi pi-check-circle' : 'pi pi-circle'"
class="layout-option__icon"
/>
<span class="layout-option__label">Layout Rail</span>
<span v-if="layoutConfig.variant === 'rail'" class="layout-option__badge layout-option__badge--default">Padrão</span>
</button>
<!-- Layout Clássico -->
<button
type="button"
class="layout-option"
:class="{ 'layout-option--active': layoutConfig.variant === 'classic' }"
@click="setVariant('classic')"
>
<i
:class="layoutConfig.variant === 'classic' ? 'pi pi-check-circle' : 'pi pi-circle'"
class="layout-option__icon"
/>
<span class="layout-option__label">Layout Clássico</span>
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.layout-option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.6rem;
border-radius: var(--border-radius, 6px);
border: 1px solid var(--surface-border);
background: var(--surface-ground);
font-size: 0.8rem;
cursor: pointer;
text-align: left;
transition: border-color 0.15s, background 0.15s;
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.6rem;
border-radius: var(--border-radius, 6px);
border: 1px solid var(--surface-border);
background: var(--surface-ground);
font-size: 0.8rem;
cursor: pointer;
text-align: left;
transition:
border-color 0.15s,
background 0.15s;
width: 100%;
}
.layout-option:hover {
border-color: var(--primary-color);
border-color: var(--primary-color);
}
.layout-option--active {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
}
.layout-option__icon {
font-size: 0.85rem;
color: var(--text-color-secondary);
flex-shrink: 0;
font-size: 0.85rem;
color: var(--text-color-secondary);
flex-shrink: 0;
}
.layout-option--active .layout-option__icon {
color: var(--primary-color);
color: var(--primary-color);
}
.layout-option__label {
flex: 1;
font-weight: 500;
color: var(--text-color);
flex: 1;
font-weight: 500;
color: var(--text-color);
}
.layout-option__badge {
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.01em;
padding: 0.1rem 0.45rem;
border-radius: 999px;
white-space: nowrap;
flex-shrink: 0;
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.01em;
padding: 0.1rem 0.45rem;
border-radius: 999px;
white-space: nowrap;
flex-shrink: 0;
}
.layout-option__badge--default {
background: var(--primary-color);
color: var(--primary-color-text, #fff);
background: var(--primary-color);
color: var(--primary-color-text, #fff);
}
</style>
</style>
+193 -186
View File
@@ -15,165 +15,161 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { useLayout } from '@/layout/composables/layout'
import { computed, onMounted, onBeforeUnmount, provide, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useLayout } from '@/layout/composables/layout';
import { computed, onMounted, onBeforeUnmount, provide, watch } from 'vue';
import { useRoute } from 'vue-router';
import AppFooter from './AppFooter.vue'
import AppSidebar from './AppSidebar.vue'
import AppTopbar from './AppTopbar.vue'
import AppRail from './AppRail.vue'
import AppRailPanel from './AppRailPanel.vue'
import AppRailSidebar from './AppRailSidebar.vue'
import AjudaDrawer from '@/components/AjudaDrawer.vue'
import SupportDebugBanner from '@/support/components/SupportDebugBanner.vue'
import GlobalNoticeBanner from '@/features/notices/GlobalNoticeBanner.vue'
import AppFooter from './AppFooter.vue';
import AppSidebar from './AppSidebar.vue';
import AppTopbar from './AppTopbar.vue';
import AppRail from './AppRail.vue';
import AppRailPanel from './AppRailPanel.vue';
import AppRailSidebar from './AppRailSidebar.vue';
import AjudaDrawer from '@/components/AjudaDrawer.vue';
import SupportDebugBanner from '@/support/components/SupportDebugBanner.vue';
import GlobalNoticeBanner from '@/features/notices/GlobalNoticeBanner.vue';
import AppThemeBar from './AppThemeBar.vue';
import { useConfiguratorBar } from './composables/useConfiguratorBar';
import { useNoticeStore } from '@/stores/noticeStore'
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
const { open: themeBarOpen } = useConfiguratorBar();
import { fetchDocsForPath, useAjuda } from '@/composables/useAjuda'
import { useNoticeStore } from '@/stores/noticeStore';
import { useTenantStore } from '@/stores/tenantStore';
import { useEntitlementsStore } from '@/stores/entitlementsStore';
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore';
const { drawerOpen } = useAjuda()
import { fetchDocsForPath, useAjuda } from '@/composables/useAjuda';
const { drawerOpen } = useAjuda();
const ajudaPushStyle = computed(() => ({
transition: 'padding-right 0.3s ease',
paddingRight: drawerOpen.value ? '420px' : '0'
}))
transition: 'padding-right 0.3s ease',
paddingRight: drawerOpen.value ? '420px' : '0'
}));
const route = useRoute()
const noticeStore = useNoticeStore()
const { layoutConfig, layoutState, hideMobileMenu, isDesktop, effectiveVariant } = useLayout()
const route = useRoute();
const noticeStore = useNoticeStore();
const { layoutConfig, layoutState, hideMobileMenu, isDesktop, effectiveVariant, effectiveMenuMode } = useLayout();
const layoutArea = computed(() => route.meta?.area || null)
provide('layoutArea', layoutArea)
const layoutArea = computed(() => route.meta?.area || null);
provide('layoutArea', layoutArea);
const tenantStore = useTenantStore()
const entitlementsStore = useEntitlementsStore()
const tf = useTenantFeaturesStore()
const tenantStore = useTenantStore();
const entitlementsStore = useEntitlementsStore();
const tf = useTenantFeaturesStore();
// ── Atualiza contexto dos notices ao mudar de rota ────────────
watch(
() => route.path,
(path) => noticeStore.updateContext(path, tenantStore.role),
{ immediate: false }
)
() => route.path,
(path) => noticeStore.updateContext(path, tenantStore.role),
{ immediate: false }
);
const containerClass = computed(() => {
return {
'layout-overlay': layoutConfig.menuMode === 'overlay',
'layout-static': layoutConfig.menuMode === 'static',
'layout-overlay-active': layoutState.overlayMenuActive,
'layout-mobile-active': layoutState.mobileMenuActive,
'layout-static-inactive': layoutState.staticMenuInactive
}
})
return {
'layout-overlay': effectiveMenuMode.value === 'overlay',
'layout-static': effectiveMenuMode.value === 'static',
'layout-overlay-active': layoutState.overlayMenuActive,
'layout-mobile-active': layoutState.mobileMenuActive,
'layout-static-inactive': layoutState.staticMenuInactive
};
});
function getTenantId () {
return (
tenantStore.activeTenantId ||
tenantStore.tenantId ||
tenantStore.currentTenantId ||
tenantStore.tenant?.id ||
null
)
function getTenantId() {
return tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id || null;
}
async function revalidateAfterSessionRefresh () {
try {
if (!tenantStore.loaded && !tenantStore.loading) {
await tenantStore.loadSessionAndTenant()
async function revalidateAfterSessionRefresh() {
try {
if (!tenantStore.loaded && !tenantStore.loading) {
await tenantStore.loadSessionAndTenant();
}
const tid = getTenantId();
if (!tid) return;
await Promise.allSettled([entitlementsStore.loadForTenant?.(tid, { force: true }), tf.fetchForTenant?.(tid, { force: true })]);
} catch (e) {
console.warn('[AppLayout] revalidateAfterSessionRefresh failed:', e?.message || e);
}
const tid = getTenantId()
if (!tid) return
await Promise.allSettled([
entitlementsStore.loadForTenant?.(tid, { force: true }),
tf.fetchForTenant?.(tid, { force: true })
])
} catch (e) {
console.warn('[AppLayout] revalidateAfterSessionRefresh failed:', e?.message || e)
}
}
function onSessionRefreshed () {
const p = String(route.path || '')
const isTenantArea =
p.startsWith('/admin') ||
p.startsWith('/therapist') ||
p.startsWith('/supervisor') ||
p.startsWith('/saas')
if (!isTenantArea) return
revalidateAfterSessionRefresh()
function onSessionRefreshed() {
const p = String(route.path || '');
const isTenantArea = p.startsWith('/admin') || p.startsWith('/therapist') || p.startsWith('/supervisor') || p.startsWith('/saas');
if (!isTenantArea) return;
revalidateAfterSessionRefresh();
}
watch(() => route.path, (path) => fetchDocsForPath(path), { immediate: true })
watch(
() => route.path,
(path) => fetchDocsForPath(path),
{ immediate: true }
);
onMounted(() => {
window.addEventListener('app:session-refreshed', onSessionRefreshed)
noticeStore.init(tenantStore.role, route.path)
})
window.addEventListener('app:session-refreshed', onSessionRefreshed);
noticeStore.init(tenantStore.role, route.path);
});
onBeforeUnmount(() => {
window.removeEventListener('app:session-refreshed', onSessionRefreshed)
})
window.removeEventListener('app:session-refreshed', onSessionRefreshed);
});
</script>
<template>
<!-- Fullscreen -->
<template v-if="route.meta?.fullscreen">
<router-view />
<Toast />
</template>
<!-- Fullscreen -->
<template v-if="route.meta?.fullscreen">
<router-view />
<Toast />
</template>
<!-- Layout Rail -->
<template v-else-if="effectiveVariant === 'rail'">
<div class="l2-root">
<!-- Rail de ícones: oculto em mobile ( 1200px) via CSS -->
<AppRail />
<div class="l2-body">
<AppTopbar />
<div class="l2-content">
<AppRailPanel />
<div class="l2-main" :style="ajudaPushStyle">
<router-view />
</div>
<!-- Layout Rail -->
<template v-else-if="effectiveVariant === 'rail'">
<div class="l2-root">
<!-- Rail de ícones: oculto em mobile ( xl (1280px)) via CSS -->
<AppRail />
<div class="l2-body">
<AppTopbar />
<div class="l2-content">
<AppRailPanel />
<div class="l2-main" :style="ajudaPushStyle">
<router-view />
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Sidebar mobile exclusiva do Rail -->
<AppRailSidebar />
<!-- Overlay escuro ao abrir sidebar mobile -->
<div
v-if="layoutState.mobileMenuActive"
class="l2-mobile-overlay"
@click="hideMobileMenu"
/>
<AjudaDrawer />
<Toast />
</template>
<!-- Sidebar mobile exclusiva do Rail -->
<AppRailSidebar />
<!-- Overlay escuro ao abrir sidebar mobile -->
<div v-if="layoutState.mobileMenuActive" class="l2-mobile-overlay" @click="hideMobileMenu" />
<AjudaDrawer />
<Toast />
</template>
<!-- Layout Clássico melhorado -->
<template v-else>
<div class="layout-wrapper" :class="containerClass">
<AppTopbar />
<AppSidebar />
<div class="layout-main-container" :style="ajudaPushStyle">
<div class="layout-main">
<router-view />
<!-- Layout Clássico melhorado -->
<template v-else>
<div class="layout-wrapper" :class="containerClass">
<AppTopbar />
<AppSidebar />
<div class="layout-main-container" :style="ajudaPushStyle">
<div class="layout-main">
<router-view />
</div>
<AppFooter />
</div>
<div class="layout-mask animate-fadein" @click="hideMobileMenu" />
</div>
<AppFooter />
</div>
<div class="layout-mask animate-fadein" @click="hideMobileMenu" />
</div>
<AjudaDrawer />
<Toast />
</template>
<AjudaDrawer />
<Toast />
</template>
<!-- Global fora de todos os branches, persiste em qualquer layout/rota -->
<SupportDebugBanner />
<GlobalNoticeBanner />
<!-- Barra de tema persiste em qualquer layout/rota -->
<Transition name="theme-bar">
<AppThemeBar v-if="themeBarOpen" />
</Transition>
<!-- Global fora de todos os branches, persiste em qualquer layout/rota -->
<SupportDebugBanner />
<GlobalNoticeBanner />
</template>
<style>
@@ -185,31 +181,31 @@ onBeforeUnmount(() => {
/* ── Global Notice Banner: variável de altura ─────────────
Definida aqui como fallback; o componente altera via JS */
:root {
--notice-banner-height: 0px;
--notice-banner-height: 0px;
}
/* ── Topbar: desce pelo banner ─────────────────────────── */
.rail-topbar {
top: var(--notice-banner-height) !important;
top: var(--notice-banner-height) !important;
}
/* ── Sidebar — sempre abaixo da topbar fixed (56px) ────────
Desce pelo banner também. */
.layout-sidebar {
position: fixed !important;
top: calc(56px + var(--notice-banner-height)) !important;
left: 0 !important;
height: calc(100vh - 56px - var(--notice-banner-height)) !important;
border-radius: 0 !important;
padding: 0 !important;
box-shadow: 2px 0 6px rgba(0,0,0,.06) !important;
border-right: 1px solid var(--surface-border) !important;
z-index: 999;
position: fixed !important;
top: calc(56px + var(--notice-banner-height)) !important;
left: 0 !important;
height: calc(100vh - 56px - var(--notice-banner-height)) !important;
border-radius: 0 !important;
padding: 0 !important;
box-shadow: 2px 0 6px rgba(0, 0, 0, 0.06) !important;
border-right: 1px solid var(--surface-border) !important;
z-index: 999;
}
/* ── Topbar no layout Clássico — sempre tela toda, acima da sidebar */
.layout-wrapper .rail-topbar {
z-index: 1000 !important;
z-index: 1000 !important;
}
/* ── Conteúdo — margem esquerda por modo ───────────────────
@@ -217,88 +213,99 @@ onBeforeUnmount(() => {
Static inativo: sem margem
Overlay : sem margem (sidebar flutua sobre o conteúdo) */
.layout-main-container {
margin-left: 20rem !important;
padding-left: 0 !important;
padding-top: calc(56px + var(--notice-banner-height)) !important;
margin-left: 20rem !important;
padding-left: 0 !important;
padding-top: calc(56px + var(--notice-banner-height)) !important;
}
.layout-overlay .layout-main-container,
.layout-static-inactive .layout-main-container {
margin-left: 0 !important;
}
@media (max-width: 1200px) {
.layout-main-container {
margin-left: 0 !important;
}
}
@media (width <= theme(--breakpoint-xl, 1280px)) {
.layout-main-container {
margin-left: 0 !important;
}
}
/* ── Overlay: hambúrguer sempre visível ─────────────────────
Em overlay a sidebar não ocupa espaço fixo — o botão precisa
estar disponível em qualquer largura de tela. */
.layout-overlay .rail-topbar__hamburger {
display: grid !important;
display: grid !important;
}
/* ── AppThemeBar — slide-up ao abrir, slide-down ao fechar ───
A transição está definida no próprio componente (.theme-bar).
Aqui apenas declaramos o estado inicial (enter) / final (leave). */
.theme-bar-enter-from,
.theme-bar-leave-to {
transform: translateY(100%);
}
</style>
<style scoped>
/* ─── Layout Rail ─────────────────────────────── */
.l2-root {
position: fixed;
top: var(--notice-banner-height, 0px); /* desce pelo banner */
left: 0;
right: 0;
bottom: 0;
display: flex;
overflow: hidden;
background: var(--surface-ground);
position: fixed;
top: var(--notice-banner-height, 0px); /* desce pelo banner */
left: 0;
right: 0;
bottom: 0;
display: flex;
overflow: hidden;
background: var(--surface-ground);
}
.l2-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
padding-top: 56px; /* compensa topbar — banner já absorvido pelo l2-root */
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
padding-top: 56px; /* compensa topbar — banner já absorvido pelo l2-root */
}
.l2-content {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
}
.l2-main {
flex: 1;
min-width: 0;
overflow-y: auto;
overflow-x: hidden;
--layout-sticky-top: 0px;
flex: 1;
min-width: 0;
overflow-y: auto;
overflow-x: hidden;
--layout-sticky-top: 0px;
}
/* Rail de ícones: oculto em mobile */
@media (max-width: 1200px) {
.l2-root :deep(.rail) {
display: none;
}
/* Painel lateral: também oculto em mobile (substituído pelo AppRailSidebar) */
.l2-content :deep(.rp) {
display: none;
}
@media (width <= theme(--breakpoint-xl, 1280px)) {
.l2-root :deep(.rail) {
display: none;
}
/* Painel lateral: também oculto em mobile (substituído pelo AppRailSidebar) */
.l2-content :deep(.rp) {
display: none;
}
}
/* Overlay escuro ao abrir sidebar mobile no Rail */
.l2-mobile-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 98;
animation: fadeIn 0.2s ease;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 98;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>
</style>
+333 -370
View File
@@ -15,22 +15,21 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLayout } from '@/layout/composables/layout'
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useLayout } from '@/layout/composables/layout';
import AppMenuItem from './AppMenuItem.vue'
import AppMenuFooterPanel from './AppMenuFooterPanel.vue'
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
import AppMenuItem from './AppMenuItem.vue';
import AppMenuFooterPanel from './AppMenuFooterPanel.vue';
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
import { useMenuStore } from '@/stores/menuStore';
import { useMenuStore } from '@/stores/menuStore'
const route = useRoute();
const router = useRouter();
const { layoutState } = useLayout();
const route = useRoute()
const router = useRouter()
const { layoutState } = useLayout()
const menuStore = useMenuStore()
const menuStore = useMenuStore();
// ======================================================
// ✅ Blindagem anti-"menu some"
@@ -39,444 +38,408 @@ const menuStore = useMenuStore()
// ======================================================
// raw (pode piscar vazio)
const rawModel = computed(() => menuStore.model || [])
const rawModel = computed(() => menuStore.model || []);
// último menu válido
const lastGoodModel = ref([])
const lastGoodModel = ref([]);
// debounce curto para aceitar "vazio real" (ex.: logout) sem travar UI
let acceptEmptyT = null
let acceptEmptyT = null;
function setLastGoodIfValid (m) {
if (Array.isArray(m) && m.length) {
lastGoodModel.value = m
}
function setLastGoodIfValid(m) {
if (Array.isArray(m) && m.length) {
lastGoodModel.value = m;
}
}
watch(
rawModel,
(m) => {
// se veio com itens, atualiza na hora
if (Array.isArray(m) && m.length) {
if (acceptEmptyT) clearTimeout(acceptEmptyT)
setLastGoodIfValid(m)
return
}
rawModel,
(m) => {
// se veio com itens, atualiza na hora
if (Array.isArray(m) && m.length) {
if (acceptEmptyT) clearTimeout(acceptEmptyT);
setLastGoodIfValid(m);
return;
}
// se veio vazio, NÃO derruba o menu imediatamente.
// Só aceita vazio se continuar vazio por um tempinho.
if (acceptEmptyT) clearTimeout(acceptEmptyT)
acceptEmptyT = setTimeout(() => {
// se ainda estiver vazio, e você quiser realmente limpar, faça aqui.
// Por padrão, manteremos o último menu válido para evitar UX quebrada.
// Se quiser limpar em logout, o logout deve limpar lastGoodModel explicitamente.
}, 250)
},
{ immediate: true, deep: false }
)
// se veio vazio, NÃO derruba o menu imediatamente.
// Só aceita vazio se continuar vazio por um tempinho.
if (acceptEmptyT) clearTimeout(acceptEmptyT);
acceptEmptyT = setTimeout(() => {
// se ainda estiver vazio, e você quiser realmente limpar, faça aqui.
// Por padrão, manteremos o último menu válido para evitar UX quebrada.
// Se quiser limpar em logout, o logout deve limpar lastGoodModel explicitamente.
}, 250);
},
{ immediate: true, deep: false }
);
// model final exibido (com fallback)
const model = computed(() => {
const m = rawModel.value
if (Array.isArray(m) && m.length) return m
if (Array.isArray(lastGoodModel.value) && lastGoodModel.value.length) return lastGoodModel.value
return []
})
const m = rawModel.value;
if (Array.isArray(m) && m.length) return m;
if (Array.isArray(lastGoodModel.value) && lastGoodModel.value.length) return lastGoodModel.value;
return [];
});
// ✅ rota -> activePath (NÃO fecha menu)
watch(
() => route.path,
(p) => { layoutState.activePath = p },
{ immediate: true }
)
() => route.path,
(p) => {
layoutState.activePath = p;
},
{ immediate: true }
);
// ======================================================
// 🔎 Busca no menu (mantive igual)
// ======================================================
const query = ref('')
const showResults = ref(false)
const activeIndex = ref(-1)
const forcedOpen = ref(false)
const query = ref('');
const showResults = ref(false);
const activeIndex = ref(-1);
const forcedOpen = ref(false);
const searchEl = ref(null)
const searchWrapEl = ref(null)
const searchEl = ref(null);
const searchWrapEl = ref(null);
const RECENT_KEY = 'menu_search_recent'
const recent = ref([])
const RECENT_KEY = 'menu_search_recent';
const recent = ref([]);
function loadRecent () {
try { recent.value = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]') } catch { recent.value = [] }
function loadRecent() {
try {
recent.value = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]');
} catch {
recent.value = [];
}
}
function saveRecent (q) {
const v = String(q || '').trim()
if (!v) return
const list = [v, ...recent.value.filter(x => x !== v)].slice(0, 8)
recent.value = list
localStorage.setItem(RECENT_KEY, JSON.stringify(list))
function saveRecent(q) {
const v = String(q || '').trim();
if (!v) return;
const list = [v, ...recent.value.filter((x) => x !== v)].slice(0, 8);
recent.value = list;
localStorage.setItem(RECENT_KEY, JSON.stringify(list));
}
function clearRecent () {
recent.value = []
try { localStorage.removeItem(RECENT_KEY) } catch {}
function clearRecent() {
recent.value = [];
try {
localStorage.removeItem(RECENT_KEY);
} catch {}
}
loadRecent()
loadRecent();
watch(query, (v) => {
const hasText = !!v?.trim()
if (hasText) {
forcedOpen.value = false
showResults.value = true
return
}
showResults.value = forcedOpen.value
})
function clearSearch () {
query.value = ''
activeIndex.value = -1
showResults.value = false
forcedOpen.value = false
}
function norm (s) {
return String(s || '')
.toLowerCase()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.trim()
}
function isVisibleItem (it) {
const v = it?.visible
if (typeof v === 'function') return !!v()
if (v === undefined || v === null) return true
return v !== false
}
function flattenMenu (items, trail = []) {
const out = []
for (const it of (items || [])) {
if (!isVisibleItem(it)) continue
const nextTrail = [...trail, it?.label].filter(Boolean)
if (it?.to && !it?.items?.length) {
out.push({
label: it.label || it.to,
to: it.to,
icon: it.icon,
trail: nextTrail,
// ✅ usa cálculo dinâmico vindo do navigation (quando existir)
proBadge: !!(it.__showProBadge ?? it.proBadge),
feature: it.feature || null
})
const hasText = !!v?.trim();
if (hasText) {
forcedOpen.value = false;
showResults.value = true;
return;
}
showResults.value = forcedOpen.value;
});
if (it?.items?.length) {
out.push(...flattenMenu(it.items, nextTrail))
}
}
return out
function clearSearch() {
query.value = '';
activeIndex.value = -1;
showResults.value = false;
forcedOpen.value = false;
}
const allLinks = computed(() => flattenMenu(model.value))
function norm(s) {
return String(s || '')
.toLowerCase()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.trim();
}
function isVisibleItem(it) {
const v = it?.visible;
if (typeof v === 'function') return !!v();
if (v === undefined || v === null) return true;
return v !== false;
}
function flattenMenu(items, trail = []) {
const out = [];
for (const it of items || []) {
if (!isVisibleItem(it)) continue;
const nextTrail = [...trail, it?.label].filter(Boolean);
if (it?.to && !it?.items?.length) {
out.push({
label: it.label || it.to,
to: it.to,
icon: it.icon,
trail: nextTrail,
// ✅ usa cálculo dinâmico vindo do navigation (quando existir)
proBadge: !!(it.__showProBadge ?? it.proBadge),
feature: it.feature || null
});
}
if (it?.items?.length) {
out.push(...flattenMenu(it.items, nextTrail));
}
}
return out;
}
const allLinks = computed(() => flattenMenu(model.value));
const results = computed(() => {
const q = norm(query.value)
if (!q) return []
const q = norm(query.value);
if (!q) return [];
const wantPro = q === 'pro' || q.startsWith('pro ') || q.includes(' pro')
const wantPro = q === 'pro' || q.startsWith('pro ') || q.includes(' pro');
return allLinks.value
.filter(r => {
const hay = `${norm(r.label)} ${norm(r.trail.join(' > '))} ${norm(r.to)}`
if (hay.includes(q)) return true
if (wantPro && (r.proBadge || r.feature)) return true
return false
})
.slice(0, 12)
})
return allLinks.value
.filter((r) => {
const hay = `${norm(r.label)} ${norm(r.trail.join(' > '))} ${norm(r.to)}`;
if (hay.includes(q)) return true;
if (wantPro && (r.proBadge || r.feature)) return true;
return false;
})
.slice(0, 12);
});
watch(results, (list) => {
activeIndex.value = list.length ? 0 : -1
})
activeIndex.value = list.length ? 0 : -1;
});
function escapeHtml (s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
function escapeHtml(s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function highlight (text, q) {
const queryNorm = norm(q)
const raw = String(text || '')
if (!queryNorm) return escapeHtml(raw)
function highlight(text, q) {
const queryNorm = norm(q);
const raw = String(text || '');
if (!queryNorm) return escapeHtml(raw);
const rawNorm = norm(raw)
const idx = rawNorm.indexOf(queryNorm)
if (idx < 0) return escapeHtml(raw)
const rawNorm = norm(raw);
const idx = rawNorm.indexOf(queryNorm);
if (idx < 0) return escapeHtml(raw);
const before = escapeHtml(raw.slice(0, idx))
const mid = escapeHtml(raw.slice(idx, idx + queryNorm.length))
const after = escapeHtml(raw.slice(idx + queryNorm.length))
return `${before}<mark class="px-1 rounded bg-yellow-200/40">${mid}</mark>${after}`
const before = escapeHtml(raw.slice(0, idx));
const mid = escapeHtml(raw.slice(idx, idx + queryNorm.length));
const after = escapeHtml(raw.slice(idx + queryNorm.length));
return `${before}<mark class="px-1 rounded bg-yellow-200/40">${mid}</mark>${after}`;
}
function onSearchKeydown (e) {
if (e.key === 'Escape') {
showResults.value = false
forcedOpen.value = false
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
if (!results.value.length) return
showResults.value = true
activeIndex.value = (activeIndex.value + 1) % results.value.length
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
if (!results.value.length) return
showResults.value = true
activeIndex.value = (activeIndex.value - 1 + results.value.length) % results.value.length
return
}
if (e.key === 'Enter') {
if (showResults.value && results.value.length && activeIndex.value >= 0) {
e.preventDefault()
goTo(results.value[activeIndex.value])
function onSearchKeydown(e) {
if (e.key === 'Escape') {
showResults.value = false;
forcedOpen.value = false;
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!results.value.length) return;
showResults.value = true;
activeIndex.value = (activeIndex.value + 1) % results.value.length;
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (!results.value.length) return;
showResults.value = true;
activeIndex.value = (activeIndex.value - 1 + results.value.length) % results.value.length;
return;
}
if (e.key === 'Enter') {
if (showResults.value && results.value.length && activeIndex.value >= 0) {
e.preventDefault();
goTo(results.value[activeIndex.value]);
}
}
}
}
function isTypingTarget (el) {
if (!el) return false
const tag = (el.tagName || '').toLowerCase()
return tag === 'input' || tag === 'textarea' || el.isContentEditable
function isTypingTarget(el) {
if (!el) return false;
const tag = (el.tagName || '').toLowerCase();
return tag === 'input' || tag === 'textarea' || el.isContentEditable;
}
function focusSearch () {
forcedOpen.value = true
showResults.value = true
function focusSearch() {
forcedOpen.value = true;
showResults.value = true;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const inst = searchEl.value
const input =
inst?.$el?.tagName === 'INPUT'
? inst.$el
: inst?.$el?.querySelector?.('input')
requestAnimationFrame(() => {
const inst = searchEl.value;
const input = inst?.$el?.tagName === 'INPUT' ? inst.$el : inst?.$el?.querySelector?.('input');
input?.focus?.()
})
})
input?.focus?.();
});
});
}
function onGlobalKeydown (e) {
if (isTypingTarget(document.activeElement)) return
function onGlobalKeydown(e) {
if (isTypingTarget(document.activeElement)) return;
const isK = e.key?.toLowerCase() === 'k'
const withCmdOrCtrl = e.ctrlKey || e.metaKey
const isK = e.key?.toLowerCase() === 'k';
const withCmdOrCtrl = e.ctrlKey || e.metaKey;
if (withCmdOrCtrl && isK) {
e.preventDefault()
e.stopPropagation()
focusSearch()
}
if (withCmdOrCtrl && isK) {
e.preventDefault();
e.stopPropagation();
focusSearch();
}
}
function applyRecent (q) {
query.value = q
forcedOpen.value = true
showResults.value = true
activeIndex.value = 0
nextTick(() => focusSearch())
function applyRecent(q) {
query.value = q;
forcedOpen.value = true;
showResults.value = true;
activeIndex.value = 0;
nextTick(() => focusSearch());
}
function onDocMouseDown (e) {
if (!showResults.value) return
const root = searchWrapEl.value
if (!root) return
if (!root.contains(e.target)) {
showResults.value = false
forcedOpen.value = false
}
function onDocMouseDown(e) {
if (!showResults.value) return;
const root = searchWrapEl.value;
if (!root) return;
if (!root.contains(e.target)) {
showResults.value = false;
forcedOpen.value = false;
}
}
onMounted(() => {
window.addEventListener('keydown', onGlobalKeydown, true)
document.addEventListener('mousedown', onDocMouseDown)
})
window.addEventListener('keydown', onGlobalKeydown, true);
document.addEventListener('mousedown', onDocMouseDown);
});
onBeforeUnmount(() => {
if (acceptEmptyT) clearTimeout(acceptEmptyT)
window.removeEventListener('keydown', onGlobalKeydown, true)
document.removeEventListener('mousedown', onDocMouseDown)
})
if (acceptEmptyT) clearTimeout(acceptEmptyT);
window.removeEventListener('keydown', onGlobalKeydown, true);
document.removeEventListener('mousedown', onDocMouseDown);
});
async function goTo (r) {
saveRecent(query.value)
async function goTo(r) {
saveRecent(query.value);
query.value = ''
showResults.value = false
activeIndex.value = -1
forcedOpen.value = false
query.value = '';
showResults.value = false;
activeIndex.value = -1;
forcedOpen.value = false;
await router.push(r.to)
await router.push(r.to);
}
// ==============================
// Quick create
// ==============================
const quickDialog = ref(false)
function onQuickCreate () { quickDialog.value = true }
function onQuickCreated () { quickDialog.value = false }
const quickDialog = ref(false);
function onQuickCreate() {
quickDialog.value = true;
}
function onQuickCreated() {
quickDialog.value = false;
}
function onSearchFocus () {
if (!query.value?.trim()) {
forcedOpen.value = true
showResults.value = true
}
function onSearchFocus() {
if (!query.value?.trim()) {
forcedOpen.value = true;
showResults.value = true;
}
}
</script>
<template>
<div class="flex flex-col h-full">
<!-- 🔎 TOPO FIXO -->
<div ref="searchWrapEl" class="px-3 pt-3 pb-2 border-b border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative">
<div
aria-hidden="true"
style="position:absolute; left:-9999px; top:-9999px; width:1px; height:1px; overflow:hidden;"
>
<input type="email" name="email" autocomplete="username" tabindex="-1" disabled />
<input type="password" name="password" autocomplete="current-password" tabindex="-1" disabled />
<div class="flex flex-col h-full">
<!-- 🔎 TOPO FIXO -->
<div ref="searchWrapEl" class="px-3 pt-3 pb-2 border-b border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative">
<div aria-hidden="true" style="position: absolute; left: -9999px; top: -9999px; width: 1px; height: 1px; overflow: hidden">
<input type="email" name="email" autocomplete="username" tabindex="-1" disabled />
<input type="password" name="password" autocomplete="current-password" tabindex="-1" disabled />
</div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
ref="searchEl"
id="menu_search"
name="menu_search"
type="search"
inputmode="search"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
data-lpignore="true"
data-1p-ignore="true"
v-model="query"
class="w-full pr-10"
variant="filled"
@focus="onSearchFocus"
@keydown="onSearchKeydown"
/>
</IconField>
<label for="menu_search">Encontrar menu...</label>
</FloatLabel>
<button v-if="query.trim()" type="button" class="absolute right-2 top-1/2 -translate-y-1/2 opacity-70 hover:opacity-100" @mousedown.prevent="clearSearch" aria-label="Limpar busca">
<i class="pi pi-times" />
</button>
</div>
<div v-if="showResults && !query.trim() && recent.length" class="mt-2 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden">
<div class="px-3 py-2 text-xs opacity-70 flex items-center justify-content-between">
<span>Recentes</span>
<button type="button" class="opacity-70 hover:opacity-100" @mousedown.prevent="clearRecent" aria-label="Limpar recentes">
<i class="pi pi-trash" />
</button>
</div>
<button v-for="q in recent" :key="q" class="w-full text-left px-3 py-2 hover:bg-[var(--surface-hover)] flex items-center gap-2" type="button" @click.stop.prevent="applyRecent(q)">
<i class="pi pi-history opacity-70" />
<div class="flex-1">{{ q }}</div>
</button>
</div>
<div v-else-if="showResults && results.length" class="mt-2 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden">
<button
v-for="(r, i) in results"
:key="String(r.to)"
type="button"
@mousedown.prevent="goTo(r)"
:class="['w-full text-left px-3 py-2 flex items-center gap-2', i === activeIndex ? 'bg-[var(--surface-hover)]' : 'hover:bg-[var(--surface-hover)]']"
>
<i v-if="r.icon" :class="r.icon" class="opacity-80" />
<div class="flex flex-col flex-1">
<div class="font-medium leading-tight" v-html="highlight(r.label, query)" />
<small class="opacity-70">{{ r.trail.join(' > ') }}</small>
</div>
<span v-if="r.proBadge" class="text-xs px-2 py-0.5 rounded border border-[var(--surface-border)] opacity-80"> PRO </span>
</button>
</div>
<div v-else-if="showResults && query && !results.length" class="mt-2 px-3 py-2 text-sm opacity-70">Nenhum item encontrado.</div>
</div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
ref="searchEl"
id="menu_search"
name="menu_search"
type="search"
inputmode="search"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
data-lpignore="true"
data-1p-ignore="true"
v-model="query"
class="w-full pr-10"
variant="filled"
@focus="onSearchFocus"
@keydown="onSearchKeydown"
/>
</IconField>
<label for="menu_search">Encontrar menu...</label>
</FloatLabel>
<button
v-if="query.trim()"
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 opacity-70 hover:opacity-100"
@mousedown.prevent="clearSearch"
aria-label="Limpar busca"
>
<i class="pi pi-times" />
</button>
</div>
<div
v-if="showResults && !query.trim() && recent.length"
class="mt-2 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden"
>
<div class="px-3 py-2 text-xs opacity-70 flex items-center justify-content-between">
<span>Recentes</span>
<button
type="button"
class="opacity-70 hover:opacity-100"
@mousedown.prevent="clearRecent"
aria-label="Limpar recentes"
>
<i class="pi pi-trash" />
</button>
<div class="flex-1 overflow-y-auto">
<ul class="layout-menu pb-20">
<template v-for="(item, i) in model" :key="i">
<AppMenuItem :item="item" :index="i" :root="true" @quick-create="onQuickCreate" />
</template>
</ul>
</div>
<button
v-for="q in recent"
:key="q"
class="w-full text-left px-3 py-2 hover:bg-[var(--surface-hover)] flex items-center gap-2"
type="button"
@click.stop.prevent="applyRecent(q)"
>
<i class="pi pi-history opacity-70" />
<div class="flex-1">{{ q }}</div>
</button>
</div>
<AppMenuFooterPanel />
<div
v-else-if="showResults && results.length"
class="mt-2 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden"
>
<button
v-for="(r, i) in results"
:key="String(r.to)"
type="button"
@mousedown.prevent="goTo(r)"
:class="[
'w-full text-left px-3 py-2 flex items-center gap-2',
i === activeIndex ? 'bg-[var(--surface-hover)]' : 'hover:bg-[var(--surface-hover)]'
]"
>
<i v-if="r.icon" :class="r.icon" class="opacity-80" />
<div class="flex flex-col flex-1">
<div class="font-medium leading-tight" v-html="highlight(r.label, query)" />
<small class="opacity-70">{{ r.trail.join(' > ') }}</small>
</div>
<span
v-if="r.proBadge"
class="text-xs px-2 py-0.5 rounded border border-[var(--surface-border)] opacity-80"
>
PRO
</span>
</button>
</div>
<div v-else-if="showResults && query && !results.length" class="mt-2 px-3 py-2 text-sm opacity-70">
Nenhum item encontrado.
</div>
<ComponentCadastroRapido v-model="quickDialog" title="Cadastro Rápido" table-name="patients" name-field="nome_completo" email-field="email_principal" phone-field="telefone" :extra-payload="{ status: 'Ativo' }" @created="onQuickCreated" />
</div>
<div class="flex-1 overflow-y-auto">
<ul class="layout-menu pb-20">
<template v-for="(item, i) in model" :key="i">
<AppMenuItem :item="item" :index="i" :root="true" @quick-create="onQuickCreate" />
</template>
</ul>
</div>
<AppMenuFooterPanel />
<ComponentCadastroRapido
v-model="quickDialog"
title="Cadastro Rápido"
table-name="patients"
name-field="nome_completo"
email-field="email_principal"
phone-field="telefone"
:extra-payload="{ status: 'Ativo' }"
@created="onQuickCreated"
/>
</div>
</template>
</template>
+308 -182
View File
@@ -15,212 +15,338 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import Popover from 'primevue/popover'
import Popover from 'primevue/popover';
import { sessionUser, sessionRole } from '@/app/session'
import { supabase } from '@/lib/supabase/client'
import { useRoleGuard } from '@/composables/useRoleGuard'
import { sessionUser, sessionRole } from '@/app/session';
import { supabase } from '@/lib/supabase/client';
import { useRoleGuard } from '@/composables/useRoleGuard';
import { useLayout } from '@/layout/composables/layout';
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence';
import { useConfiguratorBar } from '@/layout/composables/useConfiguratorBar';
const props = defineProps({
variant: { type: String, default: 'sidebar' }
})
variant: { type: String, default: 'sidebar' }
});
const router = useRouter()
const pop = ref(null)
const router = useRouter();
const pop = ref(null);
const { role, canSee } = useRoleGuard()
const { role, canSee } = useRoleGuard();
const { toggleDarkMode, isDarkTheme } = useLayout();
const { init: initSettings, queuePatch } = useUserSettingsPersistence();
const { toggle: toggleThemeBar } = useConfiguratorBar();
onMounted(() => initSettings());
function isDarkNow() {
return document.documentElement.classList.contains('app-dark');
}
async function toggleDarkAndPersist() {
try {
toggleDarkMode();
await nextTick();
const theme_mode = isDarkNow() ? 'dark' : 'light';
await queuePatch({ theme_mode }, { flushNow: true });
} catch (e) {
console.error('[FooterPanel][theme] falhou:', e?.message || e);
}
}
const initials = computed(() => {
const name = sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || ''
const parts = String(name).trim().split(/\s+/).filter(Boolean)
const a = parts[0]?.[0] || 'U'
const b = parts.length > 1 ? parts[parts.length - 1][0] : ''
return (a + b).toUpperCase()
})
const name = sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || '';
const parts = String(name).trim().split(/\s+/).filter(Boolean);
const a = parts[0]?.[0] || 'U';
const b = parts.length > 1 ? parts[parts.length - 1][0] : '';
return (a + b).toUpperCase();
});
const label = computed(() => {
const name = sessionUser.value?.user_metadata?.full_name
return name || sessionUser.value?.email || 'Conta'
})
const name = sessionUser.value?.user_metadata?.full_name;
return name || sessionUser.value?.email || 'Conta';
});
const sublabel = computed(() => {
const r = role.value || sessionRole.value
if (!r) return 'Sessão'
if (r === 'clinic_admin' || r === 'tenant_admin' || r === 'admin') return 'Administrador'
if (r === 'therapist') return 'Terapeuta'
if (r === 'portal_user' || r === 'patient') return 'Portal'
return r
})
const r = role.value || sessionRole.value;
if (!r) return 'Sessão';
if (r === 'clinic_admin' || r === 'tenant_admin' || r === 'admin') return 'Administrador';
if (r === 'therapist') return 'Terapeuta';
if (r === 'portal_user' || r === 'patient') return 'Portal';
return r;
});
const avatarUrl = computed(() => sessionUser.value?.user_metadata?.avatar_url || null)
const avatarUrl = computed(() => sessionUser.value?.user_metadata?.avatar_url || null);
function toggle (e) { pop.value?.toggle(e) }
function close () { try { pop.value?.hide() } catch {} }
async function safePush (target, fallback) {
try {
const r = router.resolve(target)
if (r?.matched?.length) return await router.push(target)
} catch {}
if (fallback) { try { return await router.push(fallback) } catch {} }
return router.push('/')
function toggle(e) {
pop.value?.toggle(e);
}
function close() {
try {
pop.value?.hide();
} catch {}
}
function goMyProfile () { close(); safePush({ name: 'account-profile' }, '/account/profile') }
function goSecurity () { close(); safePush({ name: 'account-security' }, '/account/security') }
function goSettings () {
close()
if (canSee('settings.view')) return safePush({ name: 'ConfiguracoesAgenda' }, '/admin/settings')
return safePush({ name: 'portal-sessoes' }, '/portal')
}
async function signOut () {
close()
try { await supabase.auth.signOut() } catch {}
finally { router.push('/auth/login') }
async function safePush(target, fallback) {
try {
const r = router.resolve(target);
if (r?.matched?.length) return await router.push(target);
} catch {}
if (fallback) {
try {
return await router.push(fallback);
} catch {}
}
return router.push('/');
}
defineExpose({ toggle })
function goMyProfile() {
close();
safePush({ name: 'account-profile' }, '/account/profile');
}
function goSecurity() {
close();
safePush({ name: 'account-security' }, '/account/security');
}
function goSettings() {
close();
if (canSee('settings.view')) return safePush({ name: 'ConfiguracoesAgenda' }, '/admin/settings');
return safePush({ name: 'portal-sessoes' }, '/portal');
}
function goMyPlan() {
close();
const r = role.value || sessionRole.value;
if (r === 'clinic_admin' || r === 'tenant_admin' || r === 'admin') {
return safePush({ name: 'admin-meu-plano' }, '/admin/meu-plano');
}
if (r === 'supervisor') {
return safePush({ name: 'supervisor.meu-plano' }, '/supervisor/meu-plano');
}
if (r === 'editor') {
return safePush({ name: 'editor-meu-plano' }, '/editor/meu-plano');
}
if (r === 'portal_user' || r === 'patient') {
return safePush({ name: 'portal-meu-plano' }, '/portal/meu-plano');
}
return safePush({ name: 'therapist-meu-plano' }, '/therapist/meu-plano');
}
// ── Logout com confirmação ────────────────────────────────────
const signingOut = ref(false);
const dots = ref('.');
let _signOutTimer = null;
let _dotsTimer = null;
function initSignOut() {
signingOut.value = true;
dots.value = '.';
_dotsTimer = setInterval(() => {
dots.value = dots.value.length >= 3 ? '.' : dots.value + '.';
}, 400);
_signOutTimer = setTimeout(async () => {
_clearSignOutTimers();
try {
await supabase.auth.signOut();
} catch {
} finally {
router.push('/auth/login');
}
}, 3000);
}
function cancelSignOut() {
_clearSignOutTimers();
signingOut.value = false;
}
function _clearSignOutTimers() {
clearTimeout(_signOutTimer);
clearInterval(_dotsTimer);
_signOutTimer = null;
_dotsTimer = null;
}
onUnmounted(_clearSignOutTimers);
defineExpose({ toggle });
</script>
<template>
<!-- SIDEBAR: trigger + popover -->
<template v-if="variant === 'sidebar'">
<div class="sticky bottom-0 z-20 border-t border-[var(--surface-border)] bg-[var(--surface-card)]">
<button
type="button"
class="w-full px-3 py-3 flex items-center gap-3 hover:bg-[var(--surface-ground)] transition-colors duration-150"
@click="toggle"
>
<img v-if="avatarUrl" :src="avatarUrl" class="h-9 w-9 rounded-xl object-cover border border-[var(--surface-border)]" alt="avatar" />
<div v-else class="h-9 w-9 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center text-sm font-semibold">{{ initials }}</div>
<div class="min-w-0 flex-1 text-left">
<div class="truncate text-sm font-semibold text-[var(--text-color)]">{{ label }}</div>
<div class="truncate text-xs text-[var(--text-color-secondary)]">{{ sublabel }}</div>
</div>
<i class="pi pi-angle-up text-xs opacity-40" />
</button>
<!-- SIDEBAR: trigger + popover -->
<template v-if="variant === 'sidebar'">
<div class="sticky bottom-0 z-20 border-t border-[var(--surface-border)] bg-[var(--surface-card)]">
<button type="button" class="w-full px-3 py-3 flex items-center gap-3 hover:bg-[var(--surface-ground)] transition-colors duration-150" @click="toggle">
<img v-if="avatarUrl" :src="avatarUrl" class="h-9 w-9 rounded-xl object-cover border border-[var(--surface-border)]" alt="avatar" />
<div v-else class="h-9 w-9 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center text-sm font-semibold">{{ initials }}</div>
<div class="min-w-0 flex-1 text-left">
<div class="truncate text-sm font-semibold text-[var(--text-color)]">{{ label }}</div>
<div class="truncate text-xs text-[var(--text-color-secondary)]">{{ sublabel }}</div>
</div>
<i class="pi pi-angle-up text-xs opacity-40" />
</button>
<Popover ref="pop" appendTo="body">
<!-- conteúdo reutilizado via template inline -->
<template v-if="true">
<div class="w-[224px] overflow-hidden rounded-[inherit]">
<!-- Header -->
<div class="flex items-center gap-2.5 px-3.5 pt-3.5 pb-3 bg-[var(--primary-color)]/[0.06] border-b border-[var(--surface-border)]">
<div class="relative shrink-0">
<img v-if="avatarUrl" :src="avatarUrl" class="w-9 h-9 rounded-[10px] object-cover ring-[1.5px] ring-[var(--primary-color)]/30" alt="avatar" />
<div v-else class="w-9 h-9 rounded-[10px] grid place-items-center text-[1rem] font-bold text-[var(--primary-color)] bg-[var(--primary-color)]/10 ring-[1.5px] ring-[var(--primary-color)]/20">{{ initials }}</div>
<span class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-green-500 border-2 border-[var(--surface-card)]" />
</div>
<div class="min-w-0 flex flex-col gap-px">
<span class="text-[1rem] font-bold text-[var(--text-color)] truncate tracking-tight">{{ label }}</span>
<span class="text-[0.85rem] text-[var(--text-color-secondary)] truncate opacity-70">{{ sessionUser?.email }}</span>
</div>
<Popover ref="pop" appendTo="body">
<!-- conteúdo reutilizado via template inline -->
<template v-if="true">
<div class="w-[224px] overflow-hidden rounded-[inherit]">
<!-- Header -->
<div class="flex items-center gap-2.5 px-3.5 pt-3.5 pb-3 bg-[var(--primary-color)]/[0.06] border-b border-[var(--surface-border)]">
<div class="relative shrink-0">
<img v-if="avatarUrl" :src="avatarUrl" class="w-9 h-9 rounded-[10px] object-cover ring-[1.5px] ring-[var(--primary-color)]/30" alt="avatar" />
<div v-else class="w-9 h-9 rounded-[10px] grid place-items-center text-[1rem] font-bold text-[var(--primary-color)] bg-[var(--primary-color)]/10 ring-[1.5px] ring-[var(--primary-color)]/20">{{ initials }}</div>
<span class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-green-500 border-2 border-[var(--surface-card)]" />
</div>
<div class="min-w-0 flex flex-col gap-px">
<span class="text-[1rem] font-bold text-[var(--text-color)] truncate tracking-tight">{{ label }}</span>
<span class="text-[0.85rem] text-[var(--text-color-secondary)] truncate opacity-70">{{ sessionUser?.email }}</span>
</div>
</div>
<!-- Nav items -->
<div class="py-1.5">
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="goMyProfile">
<i class="pi pi-user text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Meu perfil
</button>
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="goMyPlan">
<i class="pi pi-credit-card text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Meu Plano
</button>
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="goSecurity">
<i class="pi pi-shield text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Segurança
</button>
<button
v-if="canSee('settings.view')"
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150"
@click="goSettings"
>
<i class="pi pi-cog text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Configurações
</button>
<div class="my-1 border-t border-[var(--surface-border)]" />
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="toggleDarkAndPersist">
<i :class="['pi', isDarkTheme ? 'pi-sun' : 'pi-moon', 'text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150']" />
{{ isDarkTheme ? 'Modo claro' : 'Modo escuro' }}
</button>
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="toggleThemeBar">
<i class="pi pi-palette text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Cores do tema
</button>
</div>
<!-- Footer: Sair -->
<div class="border-t border-[var(--surface-border)] py-1.5">
<button v-if="!signingOut" class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-red-500 hover:bg-red-500/[0.06] hover:pl-4 transition-all duration-150" @click="initSignOut">
<i class="pi pi-sign-out text-[0.72rem] opacity-60 group-hover:opacity-100 transition-opacity duration-150" />
Sair
</button>
<div v-else class="flex items-center gap-2 w-full px-3.5 py-[7px]">
<span class="flex items-center gap-2.5 flex-1 text-[1rem] font-medium text-red-400 select-none">
<i class="pi pi-sign-out text-[0.72rem] opacity-60" />
Saindo{{ dots }}
</span>
<button class="shrink-0 text-[0.72rem] text-[var(--text-color-secondary)] hover:text-[var(--text-color)] border border-[var(--surface-border)] rounded px-2 py-0.5 transition-colors duration-150" @click="cancelSignOut">
Cancelar
</button>
</div>
</div>
</div>
</template>
</Popover>
</div>
</template>
<!-- RAIL: o popover, trigger externo -->
<template v-else>
<Popover ref="pop" appendTo="body">
<div class="w-[224px] overflow-hidden rounded-[inherit]">
<!-- Header -->
<div class="flex items-center gap-2.5 px-3.5 pt-3.5 pb-3 bg-[var(--primary-color)]/[0.06] border-b border-[var(--surface-border)]">
<div class="relative shrink-0">
<img v-if="avatarUrl" :src="avatarUrl" class="w-9 h-9 rounded-[10px] object-cover ring-[1.5px] ring-[var(--primary-color)]/30" alt="avatar" />
<div v-else class="w-9 h-9 rounded-[10px] grid place-items-center text-[1rem] font-bold text-[var(--primary-color)] bg-[var(--primary-color)]/10 ring-[1.5px] ring-[var(--primary-color)]/20">{{ initials }}</div>
<span class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-green-500 border-2 border-[var(--surface-card)]" />
</div>
<div class="min-w-0 flex flex-col gap-px">
<span class="text-[1rem] font-bold text-[var(--text-color)] truncate tracking-tight">{{ label }}</span>
<span class="text-[0.85rem] text-[var(--text-color-secondary)] truncate opacity-70">{{ sessionUser?.email }}</span>
</div>
</div>
<!-- Nav items -->
<div class="py-1.5">
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="goMyProfile">
<i class="pi pi-user text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Meu perfil
</button>
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="goMyPlan">
<i class="pi pi-credit-card text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Meu Plano
</button>
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="goSecurity">
<i class="pi pi-shield text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Segurança
</button>
<button
v-if="canSee('settings.view')"
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150"
@click="goSettings"
>
<i class="pi pi-cog text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Configurações
</button>
<div class="my-1 border-t border-[var(--surface-border)]" />
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="toggleDarkAndPersist">
<i :class="['pi', isDarkTheme ? 'pi-sun' : 'pi-moon', 'text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150']" />
{{ isDarkTheme ? 'Modo claro' : 'Modo escuro' }}
</button>
<div class="relative footer-theme-panel">
<button
type="button"
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150"
v-styleclass="{
selector: '@next',
enterFromClass: 'hidden',
enterActiveClass: 'p-anchored-overlay-enter-active',
leaveToClass: 'hidden',
leaveActiveClass: 'p-anchored-overlay-leave-active',
hideOnOutsideClick: true
}"
>
<i class="pi pi-palette text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Cores do tema
</button>
<AppConfigurator />
</div>
</div>
<!-- Footer: Sair -->
<div class="border-t border-[var(--surface-border)] py-1.5">
<button v-if="!signingOut" class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-red-500 hover:bg-red-500/[0.06] hover:pl-4 transition-all duration-150" @click="initSignOut">
<i class="pi pi-sign-out text-[0.72rem] opacity-60 group-hover:opacity-100 transition-opacity duration-150" />
Sair
</button>
<div v-else class="flex items-center gap-2 w-full px-3.5 py-[7px]">
<span class="flex items-center gap-2.5 flex-1 text-[1rem] font-medium text-red-400 select-none">
<i class="pi pi-sign-out text-[0.72rem] opacity-60" />
Saindo{{ dots }}
</span>
<button class="shrink-0 text-[0.72rem] text-[var(--text-color-secondary)] hover:text-[var(--text-color)] border border-[var(--surface-border)] rounded px-2 py-0.5 transition-colors duration-150" @click="cancelSignOut">
Cancelar
</button>
</div>
</div>
</div>
<!-- Nav items -->
<div class="py-1.5">
<button
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150"
@click="goMyProfile"
>
<i class="pi pi-user text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Meu perfil
</button>
<button
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150"
@click="goSecurity"
>
<i class="pi pi-shield text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Segurança
</button>
<button
v-if="canSee('settings.view')"
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150"
@click="goSettings"
>
<i class="pi pi-cog text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Configurações
</button>
</div>
<!-- Footer: Sair -->
<div class="border-t border-[var(--surface-border)] py-1.5">
<button
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-red-500 hover:bg-red-500/[0.06] hover:pl-4 transition-all duration-150"
@click="signOut"
>
<i class="pi pi-sign-out text-[0.72rem] opacity-60 group-hover:opacity-100 transition-opacity duration-150" />
Sair
</button>
</div>
</div>
</template>
</Popover>
</div>
</template>
<!-- RAIL: o popover, trigger externo -->
<template v-else>
<Popover ref="pop" appendTo="body">
<div class="w-[224px] overflow-hidden rounded-[inherit]">
<!-- Header -->
<div class="flex items-center gap-2.5 px-3.5 pt-3.5 pb-3 bg-[var(--primary-color)]/[0.06] border-b border-[var(--surface-border)]">
<div class="relative shrink-0">
<img v-if="avatarUrl" :src="avatarUrl" class="w-9 h-9 rounded-[10px] object-cover ring-[1.5px] ring-[var(--primary-color)]/30" alt="avatar" />
<div v-else class="w-9 h-9 rounded-[10px] grid place-items-center text-[1rem] font-bold text-[var(--primary-color)] bg-[var(--primary-color)]/10 ring-[1.5px] ring-[var(--primary-color)]/20">{{ initials }}</div>
<span class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-green-500 border-2 border-[var(--surface-card)]" />
</div>
<div class="min-w-0 flex flex-col gap-px">
<span class="text-[1rem] font-bold text-[var(--text-color)] truncate tracking-tight">{{ label }}</span>
<span class="text-[0.85rem] text-[var(--text-color-secondary)] truncate opacity-70">{{ sessionUser?.email }}</span>
</div>
</div>
<!-- Nav items -->
<div class="py-1.5">
<button
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150"
@click="goMyProfile"
>
<i class="pi pi-user text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Meu perfil
</button>
<button
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150"
@click="goSecurity"
>
<i class="pi pi-shield text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Segurança
</button>
<button
v-if="canSee('settings.view')"
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150"
@click="goSettings"
>
<i class="pi pi-cog text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Configurações
</button>
</div>
<!-- Footer: Sair -->
<div class="border-t border-[var(--surface-border)] py-1.5">
<button
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-red-500 hover:bg-red-500/[0.06] hover:pl-4 transition-all duration-150"
@click="signOut"
>
<i class="pi pi-sign-out text-[0.72rem] opacity-60 group-hover:opacity-100 transition-opacity duration-150" />
Sair
</button>
</div>
</div>
</Popover>
</template>
</Popover>
</template>
</template>
<style>
/* Zero padding nativo do PrimeVue — mínimo inevitável pois não há prop pra isso */
.p-popover-content { padding: 0 !important; }
</style>
.p-popover-content {
padding: 0 !important;
}
</style>
+214 -247
View File
@@ -15,332 +15,299 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { useLayout } from '@/layout/composables/layout'
import { computed, ref, nextTick, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useLayout } from '@/layout/composables/layout';
import { computed, ref, nextTick, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import Popover from 'primevue/popover'
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue'
import Popover from 'primevue/popover';
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { useMenuBadges } from '@/composables/useMenuBadges'
import { useTenantStore } from '@/stores/tenantStore';
import { useEntitlementsStore } from '@/stores/entitlementsStore';
import { useMenuBadges } from '@/composables/useMenuBadges';
const { layoutState, isDesktop } = useLayout()
const router = useRouter()
const pop = ref(null)
const { layoutState, isDesktop } = useLayout();
const router = useRouter();
const pop = ref(null);
const tenantStore = useTenantStore()
const entitlementsStore = useEntitlementsStore()
const menuBadges = useMenuBadges()
const tenantStore = useTenantStore();
const entitlementsStore = useEntitlementsStore();
const menuBadges = useMenuBadges();
function menuBadgeLabel (item) {
const key = item?.badgeKey
if (!key) return null
const val = menuBadges[key]?.value || 0
if (!val) return null
return key === 'agendaHoje' ? `${val} hoje` : String(val)
function menuBadgeLabel(item) {
const key = item?.badgeKey;
if (!key) return null;
const val = menuBadges[key]?.value || 0;
if (!val) return null;
return key === 'agendaHoje' ? `${val} hoje` : String(val);
}
const emit = defineEmits(['quick-create'])
const emit = defineEmits(['quick-create']);
const props = defineProps({
item: { type: Object, default: () => ({}) },
root: { type: Boolean, default: false },
parentPath: { type: String, default: null }
})
item: { type: Object, default: () => ({}) },
root: { type: Boolean, default: false },
parentPath: { type: String, default: null }
});
const fullPath = computed(() =>
props.item?.path
? (props.parentPath ? props.parentPath + props.item.path : props.item.path)
: null
)
const fullPath = computed(() => (props.item?.path ? (props.parentPath ? props.parentPath + props.item.path : props.item.path) : null));
// ==============================
// Visible: boolean OU function() -> boolean
// ==============================
const isVisible = computed(() => {
const v = props.item?.visible
if (typeof v === 'function') return !!v()
if (v === undefined || v === null) return true
return v !== false
})
const v = props.item?.visible;
if (typeof v === 'function') return !!v();
if (v === undefined || v === null) return true;
return v !== false;
});
// ==============================
// Helpers de rota: aceita string OU objeto
// ==============================
function toPath (to) {
if (!to) return ''
if (typeof to === 'string') return to
try { return router.resolve(to).path || '' } catch { return '' }
function toPath(to) {
if (!to) return '';
if (typeof to === 'string') return to;
try {
return router.resolve(to).path || '';
} catch {
return '';
}
}
// ==============================
// Active logic
// ==============================
function isSameRoute (current, target) {
const cur = typeof current === 'string' ? current : toPath(current)
const tar = typeof target === 'string' ? target : toPath(target)
if (!cur || !tar) return false
if (cur === tar) return true
// Prefix match apenas para paths com 2+ segmentos (ex: /therapist/patients).
// Paths de 1 segmento (ex: /therapist) só ativam em match exato,
// evitando que o Dashboard fique ativo em todas as sub-rotas.
const segments = tar.split('/').filter(Boolean)
return segments.length >= 2 && cur.startsWith(tar + '/')
function isSameRoute(current, target) {
const cur = typeof current === 'string' ? current : toPath(current);
const tar = typeof target === 'string' ? target : toPath(target);
if (!cur || !tar) return false;
if (cur === tar) return true;
// Prefix match apenas para paths com 2+ segmentos (ex: /therapist/patients).
// Paths de 1 segmento (ex: /therapist) só ativam em match exato,
// evitando que o Dashboard fique ativo em todas as sub-rotas.
const segments = tar.split('/').filter(Boolean);
return segments.length >= 2 && cur.startsWith(tar + '/');
}
function hasActiveDescendant (node, currentPath) {
const children = node?.items || []
for (const child of children) {
const childTo = toPath(child?.to)
if (childTo && isSameRoute(currentPath, childTo)) return true
if (child?.items?.length && hasActiveDescendant(child, currentPath)) return true
}
return false
function hasActiveDescendant(node, currentPath) {
const children = node?.items || [];
for (const child of children) {
const childTo = toPath(child?.to);
if (childTo && isSameRoute(currentPath, childTo)) return true;
if (child?.items?.length && hasActiveDescendant(child, currentPath)) return true;
}
return false;
}
const isActive = computed(() => {
const current = typeof layoutState.activePath === 'string'
? layoutState.activePath
: toPath(layoutState.activePath)
const current = typeof layoutState.activePath === 'string' ? layoutState.activePath : toPath(layoutState.activePath);
const item = props.item
const item = props.item;
if (item?.items?.length) {
if (hasActiveDescendant(item, current)) return true
return item.path ? current.startsWith(fullPath.value || '') : false
}
if (item?.items?.length) {
if (hasActiveDescendant(item, current)) return true;
return item.path ? current.startsWith(fullPath.value || '') : false;
}
const leafTo = toPath(item?.to)
return leafTo ? isSameRoute(current, leafTo) : false
})
const leafTo = toPath(item?.to);
return leafTo ? isSameRoute(current, leafTo) : false;
});
// ==============================
// ✅ PRO badge (agora 100% por entitlementsStore)
// ==============================
const showProBadge = computed(() => {
const feature = props.item?.feature
if (!props.item?.proBadge || !feature) return false
const feature = props.item?.feature;
if (!props.item?.proBadge || !feature) return false;
// se tiver a feature, NÃO é PRO (não mostra badge)
// se NÃO tiver, mostra PRO
try {
return !entitlementsStore.has(feature)
} catch {
// se der erro, não mostra (evita "PRO fantasma")
return false
}
})
// se tiver a feature, NÃO é PRO (não mostra badge)
// se NÃO tiver, mostra PRO
try {
return !entitlementsStore.has(feature);
} catch {
// se der erro, não mostra (evita "PRO fantasma")
return false;
}
});
// 🔒 locked quando precisa mostrar PRO (ou seja, não tem feature)
const isLocked = computed(() => !!(props.item?.proBadge && showProBadge.value))
const isLocked = computed(() => !!(props.item?.proBadge && showProBadge.value));
const itemDisabled = computed(() => !!props.item?.disabled)
const isBlocked = computed(() => itemDisabled.value || isLocked.value)
const itemDisabled = computed(() => !!props.item?.disabled);
const isBlocked = computed(() => itemDisabled.value || isLocked.value);
const labelText = computed(() => {
return props.item?.label || ''
})
return props.item?.label || '';
});
const itemClick = async (event, item) => {
// 🔒 locked -> CTA upgrade
if (props.item?.proBadge && isLocked.value) {
event.preventDefault()
event.stopPropagation()
// 🔒 locked -> CTA upgrade
if (props.item?.proBadge && isLocked.value) {
event.preventDefault();
event.stopPropagation();
layoutState.overlayMenuActive = false
layoutState.mobileMenuActive = false
layoutState.menuHoverActive = false
layoutState.overlayMenuActive = false;
layoutState.mobileMenuActive = false;
layoutState.menuHoverActive = false;
await nextTick()
await router.push({ name: 'upgrade', query: { feature: props.item?.feature || '' } })
return
}
if (itemDisabled.value) {
event.preventDefault()
event.stopPropagation()
return
}
if (item?.command) item.command({ originalEvent: event, item })
if (item?.items?.length) {
event.preventDefault()
event.stopPropagation()
if (isActive.value) {
layoutState.activePath = props.parentPath || ''
} else {
layoutState.activePath = fullPath.value || ''
layoutState.menuHoverActive = true
await nextTick();
await router.push({ name: 'upgrade', query: { feature: props.item?.feature || '' } });
return;
}
return
}
if (item?.to) layoutState.activePath = toPath(item.to)
}
if (itemDisabled.value) {
event.preventDefault();
event.stopPropagation();
return;
}
if (item?.command) item.command({ originalEvent: event, item });
if (item?.items?.length) {
event.preventDefault();
event.stopPropagation();
if (isActive.value) {
layoutState.activePath = props.parentPath || '';
} else {
layoutState.activePath = fullPath.value || '';
layoutState.menuHoverActive = true;
}
return;
}
if (item?.to) layoutState.activePath = toPath(item.to);
};
const onMouseEnter = () => {
if (isDesktop() && props.root && props.item?.items && layoutState.menuHoverActive) {
layoutState.activePath = fullPath.value || ''
}
}
if (isDesktop() && props.root && props.item?.items && layoutState.menuHoverActive) {
layoutState.activePath = fullPath.value || '';
}
};
const showCadastroDialog = ref(false)
const showCadastroDialog = ref(false);
/* ---------- POPUP + ---------- */
function togglePopover (event) {
if (isBlocked.value) return
pop.value?.toggle(event)
function togglePopover(event) {
if (isBlocked.value) return;
pop.value?.toggle(event);
}
function closePopover () {
try { pop.value?.hide() } catch {}
function closePopover() {
try {
pop.value?.hide();
} catch {}
}
function abrirCadastroRapido () {
closePopover()
emit('quick-create', { entity: props.item?.quickCreateEntity || 'patient', mode: 'rapido' })
function abrirCadastroRapido() {
closePopover();
emit('quick-create', { entity: props.item?.quickCreateEntity || 'patient', mode: 'rapido' });
}
function irCadastroCompleto () {
closePopover()
function irCadastroCompleto() {
closePopover();
layoutState.overlayMenuActive = false
layoutState.mobileMenuActive = false
layoutState.menuHoverActive = false
layoutState.overlayMenuActive = false;
layoutState.mobileMenuActive = false;
layoutState.menuHoverActive = false;
showCadastroDialog.value = true
showCadastroDialog.value = true;
}
async function irLinkCadastro () {
closePopover()
async function irLinkCadastro() {
closePopover();
layoutState.overlayMenuActive = false
layoutState.mobileMenuActive = false
layoutState.menuHoverActive = false
layoutState.overlayMenuActive = false;
layoutState.mobileMenuActive = false;
layoutState.menuHoverActive = false;
await nextTick()
const linkTo = props.item?.quickCreateLinkTo || '/therapist/patients/link-externo'
router.push(linkTo)
await nextTick();
const linkTo = props.item?.quickCreateLinkTo || '/therapist/patients/link-externo';
router.push(linkTo);
}
</script>
<template>
<PatientCadastroDialog v-if="item.quickCreate" v-model="showCadastroDialog" />
<PatientCadastroDialog v-if="item.quickCreate" v-model="showCadastroDialog" />
<li v-show="isVisible" :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActive }">
<div v-if="root" class="layout-menuitem-root-text">
{{ item.label }}
</div>
<li v-show="isVisible" :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActive }">
<div v-if="root" class="layout-menuitem-root-text">
{{ item.label }}
</div>
<div v-if="!root" class="flex align-items-center justify-content-between w-full">
<component
:is="item.to && !item.items ? 'router-link' : 'a'"
v-bind="item.to && !item.items ? { to: item.to } : { href: item.url }"
@click="itemClick($event, item)"
:class="[item.class, isBlocked ? 'opacity-60 cursor-pointer' : '', { 'active-route': isActive && !item.items }]"
:target="item.target"
tabindex="0"
@mouseenter="onMouseEnter"
class="flex align-items-center flex-1"
:aria-disabled="isBlocked ? 'true' : 'false'"
>
<i :class="item.icon" class="layout-menuitem-icon" />
<div v-if="!root" class="flex align-items-center justify-content-between w-full">
<component
:is="item.to && !item.items ? 'router-link' : 'a'"
v-bind="item.to && !item.items ? { to: item.to } : { href: item.url }"
@click="itemClick($event, item)"
:class="[item.class, isBlocked ? 'opacity-60 cursor-pointer' : '', { 'active-route': isActive && !item.items }]"
:target="item.target"
tabindex="0"
@mouseenter="onMouseEnter"
class="flex align-items-center flex-1"
:aria-disabled="isBlocked ? 'true' : 'false'"
>
<i :class="item.icon" class="layout-menuitem-icon" />
<span class="layout-menuitem-text">
{{ labelText }}
</span>
<span class="layout-menuitem-text">
{{ labelText }}
</span>
<!-- Badge PRO some quando tem entitlements -->
<span
v-if="item.proBadge && showProBadge"
class="ml-2 text-xs px-2 py-0.5 rounded border border-[var(--surface-border)] opacity-80"
>
PRO
</span>
<!-- Badge PRO some quando tem entitlements -->
<span v-if="item.proBadge && showProBadge" class="ml-2 text-xs px-2 py-0.5 rounded border border-[var(--surface-border)] opacity-80"> PRO </span>
<!-- Badge contador (agenda hoje / cadastros / agendamentos) -->
<span
v-if="menuBadgeLabel(item)"
class="ml-auto text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none"
>
{{ menuBadgeLabel(item) }}
</span>
<!-- Badge contador (agenda hoje / cadastros / agendamentos) -->
<span v-if="menuBadgeLabel(item)" class="ml-auto text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none">
{{ menuBadgeLabel(item) }}
</span>
<i v-if="item.items" class="pi pi-fw pi-angle-down layout-submenu-toggler" />
</component>
<i v-if="item.items" class="pi pi-fw pi-angle-down layout-submenu-toggler" />
</component>
<Button
v-if="item.quickCreate"
icon="pi pi-plus"
text
rounded
size="small"
class="ml-2"
:disabled="isBlocked"
@click.stop="togglePopover"
/>
</div>
<Button v-if="item.quickCreate" icon="pi pi-plus" text rounded size="small" class="ml-2" :disabled="isBlocked" @click.stop="togglePopover" />
</div>
<Popover v-if="item.quickCreate" ref="pop">
<div class="flex flex-col gap-0.5 min-w-[190px] py-0.5">
<button
class="flex items-center gap-2.5 px-3 py-2 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]"
@click="abrirCadastroRapido"
>
<div class="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-bolt text-xs" />
</div>
<div>
<div class="text-sm font-semibold text-[var(--text-color)]">Cadastro Rápido</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)]">Nome, e-mail e telefone</div>
</div>
</button>
<Popover v-if="item.quickCreate" ref="pop">
<div class="flex flex-col gap-0.5 min-w-[190px] py-0.5">
<button class="flex items-center gap-2.5 px-3 py-2 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]" @click="abrirCadastroRapido">
<div class="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-bolt text-xs" />
</div>
<div>
<div class="text-sm font-semibold text-[var(--text-color)]">Cadastro Rápido</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)]">Nome, e-mail e telefone</div>
</div>
</button>
<button
class="flex items-center gap-2.5 px-3 py-2 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]"
@click="irCadastroCompleto"
>
<div class="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 bg-emerald-500/10 text-emerald-600">
<i class="pi pi-user-plus text-xs" />
</div>
<div>
<div class="text-sm font-semibold text-[var(--text-color)]">Cadastro Completo</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)]">Formulário detalhado</div>
</div>
</button>
<button class="flex items-center gap-2.5 px-3 py-2 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]" @click="irCadastroCompleto">
<div class="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 bg-emerald-500/10 text-emerald-600">
<i class="pi pi-user-plus text-xs" />
</div>
<div>
<div class="text-sm font-semibold text-[var(--text-color)]">Cadastro Completo</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)]">Formulário detalhado</div>
</div>
</button>
<div class="mx-3 my-1 border-t border-[var(--surface-border,#e2e8f0)]" />
<div class="mx-3 my-1 border-t border-[var(--surface-border,#e2e8f0)]" />
<button
class="flex items-center gap-2.5 px-3 py-2 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]"
@click="irLinkCadastro"
>
<div class="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 bg-sky-500/10 text-sky-600">
<i class="pi pi-link text-xs" />
</div>
<div>
<div class="text-sm font-semibold text-[var(--text-color)]">Link de Cadastro</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)]">Enviar link ao paciente</div>
</div>
</button>
</div>
</Popover>
<button class="flex items-center gap-2.5 px-3 py-2 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]" @click="irLinkCadastro">
<div class="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 bg-sky-500/10 text-sky-600">
<i class="pi pi-link text-xs" />
</div>
<div>
<div class="text-sm font-semibold text-[var(--text-color)]">Link de Cadastro</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)]">Enviar link ao paciente</div>
</div>
</button>
</div>
</Popover>
<Transition v-if="item.items" name="layout-submenu">
<ul v-show="root ? true : isActive" class="layout-submenu">
<app-menu-item
v-for="child in item.items"
:key="(child.to || '') + '|' + (child.path || '') + '|' + child.label"
:item="child"
:root="false"
:parentPath="fullPath"
@quick-create="emit('quick-create', $event)"
/>
</ul>
</Transition>
</li>
</template>
<Transition v-if="item.items" name="layout-submenu">
<ul v-show="root ? true : isActive" class="layout-submenu">
<app-menu-item v-for="child in item.items" :key="(child.to || '') + '|' + (child.path || '') + '|' + child.label" :item="child" :root="false" :parentPath="fullPath" @quick-create="emit('quick-create', $event)" />
</ul>
</Transition>
</li>
</template>
+192
View File
@@ -0,0 +1,192 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Arquivo: src/layout/AppMenuPopoverContent.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, inject, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { sessionUser } from '@/app/session';
import { useRoleGuard } from '@/composables/useRoleGuard';
import { useLayout } from '@/layout/composables/layout';
import { supabase } from '@/lib/supabase/client';
import { useEntitlementsStore } from '@/stores/entitlementsStore';
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore';
import { useTenantStore } from '@/stores/tenantStore';
const emit = defineEmits(['close']);
const router = useRouter();
const { role, canSee } = useRoleGuard();
const { isDarkTheme, toggleDarkMode } = useLayout();
const queuePatch = inject('queueUserSettingsPatch', null);
const openConfigurator = inject('openConfigurator', null);
// ── Identidade ───────────────────────────────────────────────────────────
const initials = computed(() => {
const name = sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || '';
const parts = String(name).trim().split(/\s+/).filter(Boolean);
const a = parts[0]?.[0] || 'U';
const b = parts.length > 1 ? parts[parts.length - 1][0] : '';
return (a + b).toUpperCase();
});
const label = computed(() => {
const name = sessionUser.value?.user_metadata?.full_name;
return name || sessionUser.value?.email || 'Conta';
});
const avatarUrl = computed(() => sessionUser.value?.user_metadata?.avatar_url || null);
// ── Navegação ────────────────────────────────────────────────────────────
async function safePush(target, fallback) {
try {
const r = router.resolve(target);
if (r?.matched?.length) return await router.push(target);
} catch (e) {
console.warn('Erro ao resolver rota:', e);
}
if (fallback) {
try {
return await router.push(fallback);
} catch (e) {
console.warn('Erro ao navegar para fallback:', e);
}
}
return router.push('/');
}
function goMyProfile() {
emit('close');
safePush({ name: 'account-profile' }, '/account/profile');
}
function goSecurity() {
emit('close');
safePush({ name: 'account-security' }, '/account/security');
}
function goSettings() {
emit('close');
if (canSee('settings.view')) return safePush({ name: 'ConfiguracoesAgenda' }, '/admin/settings');
return safePush({ name: 'portal-sessoes' }, '/portal');
}
// ── Dark mode + persistência ─────────────────────────────────────────────
function isDarkNow() {
return document.documentElement.classList.contains('app-dark');
}
async function waitForDarkFlip(before, timeoutMs = 900) {
const start = performance.now();
while (performance.now() - start < timeoutMs) {
await nextTick();
await new Promise((r) => requestAnimationFrame(r));
if (isDarkNow() !== before) return isDarkNow();
}
return isDarkNow();
}
async function toggleDarkAndPersist() {
try {
const before = isDarkNow();
toggleDarkMode();
const after = await waitForDarkFlip(before);
await queuePatch?.({ theme_mode: after ? 'dark' : 'light' }, { flushNow: true });
} catch (e) {
console.error('[AppMenuPopoverContent][theme] falhou:', e?.message || e);
}
}
// ── Configurador ─────────────────────────────────────────────────────────
function handleOpenConfigurator() {
emit('close'); // fecha o popover primeiro
openConfigurator?.(); // abre o footer bar via AppLayout
}
// ── Sign out ─────────────────────────────────────────────────────────────
async function signOut() {
emit('close');
const tenant = useTenantStore();
const ent = useEntitlementsStore();
const tf = useTenantFeaturesStore();
try {
await supabase.auth.signOut();
} finally {
tenant.reset();
ent.invalidate();
tf.invalidate();
sessionStorage.clear();
localStorage.clear();
router.replace('/auth/login');
}
}
</script>
<template>
<div class="w-[224px] overflow-hidden rounded-[inherit]">
<!-- Header -->
<div class="flex items-center gap-2.5 px-3.5 pt-3.5 pb-3 bg-[var(--primary-color)]/[0.06] border-b border-[var(--surface-border)]">
<div class="relative shrink-0">
<img v-if="avatarUrl" :src="avatarUrl" class="w-9 h-9 rounded-[10px] object-cover ring-[1.5px] ring-[var(--primary-color)]/30" alt="avatar" />
<div v-else class="w-9 h-9 rounded-[10px] grid place-items-center text-base font-bold text-[var(--primary-color)] bg-[var(--primary-color)]/10 ring-[1.5px] ring-[var(--primary-color)]/20">{{ initials }}</div>
<span class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-green-500 border-2 border-[var(--surface-card)]" />
</div>
<div class="min-w-0 flex flex-col gap-px">
<span class="text-base font-bold text-[var(--text-color)] truncate tracking-tight">{{ label }}</span>
<span class="text-[0.8rem] text-[var(--text-color-secondary)] truncate opacity-70">{{ sessionUser?.email }}</span>
</div>
</div>
<!-- Nav items -->
<div class="py-1.5">
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-sm font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="goMyProfile">
<i class="pi pi-user text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Meu perfil
</button>
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-sm font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="goSecurity">
<i class="pi pi-shield text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Segurança
</button>
<button
v-if="canSee('settings.view')"
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-sm font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150"
@click="goSettings"
>
<i class="pi pi-cog text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Configurações
</button>
</div>
<!-- Preferências -->
<div class="border-t border-[var(--surface-border)] py-1.5">
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-sm font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="toggleDarkAndPersist">
<i :class="['pi text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150', isDarkTheme ? 'pi-sun' : 'pi-moon']" />
{{ isDarkTheme ? 'Modo claro' : 'Modo escuro' }}
</button>
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-sm font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="handleOpenConfigurator">
<i class="pi pi-palette text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Personalizar tema
</button>
</div>
<!-- Sair -->
<div class="border-t border-[var(--surface-border)] py-1.5">
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-sm font-medium text-red-500 hover:bg-red-500/[0.06] hover:pl-4 transition-all duration-150" @click="signOut">
<i class="pi pi-sign-out text-[0.72rem] opacity-60 group-hover:opacity-100 transition-opacity duration-150" />
Sair
</button>
</div>
</div>
</template>
+123 -119
View File
@@ -16,155 +16,159 @@
-->
<!-- Mini icon rail (Layout 2) -->
<script setup>
import { computed, ref } from 'vue'
import { computed, ref } from 'vue';
import { useMenuStore } from '@/stores/menuStore'
import { useLayout } from './composables/layout'
import { sessionUser } from '@/app/session'
import { useMenuStore } from '@/stores/menuStore';
import { useLayout } from './composables/layout';
import { sessionUser } from '@/app/session';
import AppMenuFooterPanel from './AppMenuFooterPanel.vue'
import AppMenuFooterPanel from './AppMenuFooterPanel.vue';
const menuStore = useMenuStore()
const { layoutState } = useLayout()
const menuStore = useMenuStore();
const { layoutState } = useLayout();
// ── Seções do rail (derivadas do model) ─────────────────────
const railSections = computed(() => {
const model = menuStore.model || []
return model
.filter(s => s.label && Array.isArray(s.items) && s.items.length)
.filter(s => s.label.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').trim() !== 'inicio')
.map(s => ({
key: s.label,
label: s.label,
icon: s.icon || s.items.find(i => i.icon)?.icon || 'pi pi-fw pi-circle',
items: s.items
}))
})
const model = menuStore.model || [];
return model
.filter((s) => s.label && Array.isArray(s.items) && s.items.length)
.filter(
(s) =>
s.label
.toLowerCase()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.trim() !== 'inicio'
)
.map((s) => ({
key: s.label,
label: s.label,
icon: s.icon || s.items.find((i) => i.icon)?.icon || 'pi pi-fw pi-circle',
items: s.items
}));
});
// ── Avatar / iniciais ────────────────────────────────────────
const avatarUrl = computed(() => sessionUser.value?.user_metadata?.avatar_url || null)
const initials = computed(() => {
const name = sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || ''
const parts = String(name).trim().split(/\s+/).filter(Boolean)
const a = parts[0]?.[0] || 'U'
const b = parts.length > 1 ? parts[parts.length - 1][0] : ''
return (a + b).toUpperCase()
})
const userName = computed(() => sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || 'Conta')
const avatarUrl = computed(() => sessionUser.value?.user_metadata?.avatar_url || null);
const initials = computed(() => {
const name = sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || '';
const parts = String(name).trim().split(/\s+/).filter(Boolean);
const a = parts[0]?.[0] || 'U';
const b = parts.length > 1 ? parts[parts.length - 1][0] : '';
return (a + b).toUpperCase();
});
const userName = computed(() => sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || 'Conta');
// ── Início (fixo) ────────────────────────────────────────────
function selectHome () {
if (layoutState.railSectionKey === '__home__' && layoutState.railPanelOpen) {
layoutState.railPanelOpen = false
} else {
layoutState.railSectionKey = '__home__'
layoutState.railPanelOpen = true
}
function selectHome() {
if (layoutState.railSectionKey === '__home__' && layoutState.railPanelOpen) {
layoutState.railPanelOpen = false;
} else {
layoutState.railSectionKey = '__home__';
layoutState.railPanelOpen = true;
}
}
const isHomeActive = computed(() =>
layoutState.railSectionKey === '__home__' && layoutState.railPanelOpen
)
const isHomeActive = computed(() => layoutState.railSectionKey === '__home__' && layoutState.railPanelOpen);
// ── Seleção de seção ─────────────────────────────────────────
function selectSection (section) {
if (layoutState.railSectionKey === section.key && layoutState.railPanelOpen) {
layoutState.railPanelOpen = false
} else {
layoutState.railSectionKey = section.key
layoutState.railPanelOpen = true
}
function selectSection(section) {
if (layoutState.railSectionKey === section.key && layoutState.railPanelOpen) {
layoutState.railPanelOpen = false;
} else {
layoutState.railSectionKey = section.key;
layoutState.railPanelOpen = true;
}
}
function isActiveSectionOrChild (section) {
if (layoutState.railSectionKey === section.key && layoutState.railPanelOpen) return true
const active = String(layoutState.activePath || '')
return section.items.some(i => {
const p = typeof i.to === 'string' ? i.to : ''
return p && active.startsWith(p)
})
function isActiveSectionOrChild(section) {
if (layoutState.railSectionKey === section.key && layoutState.railPanelOpen) return true;
const active = String(layoutState.activePath || '');
return section.items.some((i) => {
const p = typeof i.to === 'string' ? i.to : '';
return p && active.startsWith(p);
});
}
// ── Menu do usuário (rodapé) ─────────────────────────────────
const footerPanel = ref(null)
function toggleUserMenu (e) { footerPanel.value?.toggle(e) }
const footerPanel = ref(null);
function toggleUserMenu(e) {
footerPanel.value?.toggle(e);
}
</script>
<template>
<aside class="rail w-[60px] shrink-0 h-screen flex flex-col items-center border-r border-[var(--surface-border)] bg-[var(--surface-card)] z-50 select-none">
<aside class="rail w-[60px] shrink-0 h-screen flex flex-col items-center border-r border-[var(--surface-border)] bg-[var(--surface-card)] z-50 select-none">
<!-- Brand -->
<div class="w-full h-14 shrink-0 grid place-items-center border-b border-[var(--surface-border)]">
<span class="text-[1.35rem] font-extrabold leading-none text-[var(--primary-color)] [text-shadow:0_0_20px_color-mix(in_srgb,var(--primary-color)_40%,transparent)]">Ψ</span>
</div>
<!-- Brand -->
<div class="w-full h-14 shrink-0 grid place-items-center border-b border-[var(--surface-border)]">
<span class="text-[1.35rem] font-extrabold leading-none text-[var(--primary-color)] [text-shadow:0_0_20px_color-mix(in_srgb,var(--primary-color)_40%,transparent)]">Ψ</span>
</div>
<!-- Nav icons -->
<nav class="flex-1 w-full flex flex-col items-center gap-1 py-2.5 overflow-y-auto overflow-x-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" role="navigation" aria-label="Menu principal">
<!-- Início fixo -->
<button
class="rail-btn relative w-10 h-10 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-base shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
:class="isHomeActive ? 'rail-btn--active bg-[color-mix(in_srgb,var(--primary-color)_12%,transparent)] !text-[var(--primary-color)]' : ''"
v-tooltip.right="{ value: 'Início', showDelay: 0 }"
aria-label="Início"
@click="selectHome"
>
<i class="pi pi-fw pi-home" />
</button>
<!-- Nav icons -->
<nav class="flex-1 w-full flex flex-col items-center gap-1 py-2.5 overflow-y-auto overflow-x-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" role="navigation" aria-label="Menu principal">
<button
v-for="section in railSections"
:key="section.key"
class="rail-btn relative w-10 h-10 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-base shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
:class="isActiveSectionOrChild(section) ? 'rail-btn--active bg-[color-mix(in_srgb,var(--primary-color)_12%,transparent)] !text-[var(--primary-color)]' : ''"
v-tooltip.right="{ value: section.label, showDelay: 0 }"
:aria-label="section.label"
@click="selectSection(section)"
>
<i :class="section.icon" />
</button>
</nav>
<!-- Início fixo -->
<button
class="rail-btn relative w-10 h-10 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-base shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
:class="isHomeActive ? 'rail-btn--active bg-[color-mix(in_srgb,var(--primary-color)_12%,transparent)] !text-[var(--primary-color)]' : ''"
v-tooltip.right="{ value: 'Início', showDelay: 0 }"
aria-label="Início"
@click="selectHome"
>
<i class="pi pi-fw pi-home" />
</button>
<!-- Rodapé -->
<div class="w-full flex flex-col items-center gap-1.5 py-2 pb-3 border-t border-[var(--surface-border)]">
<button
class="w-9 h-9 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-[0.875rem] shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
v-tooltip.right="{ value: 'Configurações', showDelay: 0 }"
aria-label="Configurações"
@click="$router.push('/configuracoes')"
>
<i class="pi pi-fw pi-cog" />
</button>
<button
v-for="section in railSections"
:key="section.key"
class="rail-btn relative w-10 h-10 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-base shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
:class="isActiveSectionOrChild(section) ? 'rail-btn--active bg-[color-mix(in_srgb,var(--primary-color)_12%,transparent)] !text-[var(--primary-color)]' : ''"
v-tooltip.right="{ value: section.label, showDelay: 0 }"
:aria-label="section.label"
@click="selectSection(section)"
>
<i :class="section.icon" />
</button>
</nav>
<!-- Avatar trigger do menu de usuário -->
<button
class="w-9 h-9 rounded-[10px] border-none cursor-pointer overflow-hidden shrink-0 bg-[var(--surface-ground)] grid place-items-center transition-[transform,box-shadow] duration-150 hover:scale-105 hover:shadow-[0_0_0_2px_var(--primary-color)]"
v-tooltip.right="{ value: userName, showDelay: 0 }"
:aria-label="userName"
@click="toggleUserMenu"
>
<img v-if="avatarUrl" :src="avatarUrl" class="w-full h-full object-cover" :alt="userName" />
<span v-else class="text-[1rem] font-bold text-[var(--text-color)]">{{ initials }}</span>
</button>
</div>
<!-- Rodapé -->
<div class="w-full flex flex-col items-center gap-1.5 py-2 pb-3 border-t border-[var(--surface-border)]">
<button
class="w-9 h-9 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-[0.875rem] shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
v-tooltip.right="{ value: 'Configurações', showDelay: 0 }"
aria-label="Configurações"
@click="$router.push('/configuracoes')"
>
<i class="pi pi-fw pi-cog" />
</button>
<!-- Avatar trigger do menu de usuário -->
<button
class="w-9 h-9 rounded-[10px] border-none cursor-pointer overflow-hidden shrink-0 bg-[var(--surface-ground)] grid place-items-center transition-[transform,box-shadow] duration-150 hover:scale-105 hover:shadow-[0_0_0_2px_var(--primary-color)]"
v-tooltip.right="{ value: userName, showDelay: 0 }"
:aria-label="userName"
@click="toggleUserMenu"
>
<img v-if="avatarUrl" :src="avatarUrl" class="w-full h-full object-cover" :alt="userName" />
<span v-else class="text-[1rem] font-bold text-[var(--text-color)]">{{ initials }}</span>
</button>
</div>
<!-- Menu de usuário (popup via AppMenuFooterPanel) -->
<AppMenuFooterPanel ref="footerPanel" variant="rail" />
</aside>
<!-- Menu de usuário (popup via AppMenuFooterPanel) -->
<AppMenuFooterPanel ref="footerPanel" variant="rail" />
</aside>
</template>
<style scoped>
/* Indicador lateral do botão ativo — pseudo-elemento não expressável em Tailwind */
.rail-btn--active::before {
content: '';
position: absolute;
left: -10px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 22px;
border-radius: 0 3px 3px 0;
background: var(--primary-color);
content: '';
position: absolute;
left: -10px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 22px;
border-radius: 0 3px 3px 0;
background: var(--primary-color);
}
</style>
</style>
+403 -383
View File
@@ -16,463 +16,483 @@
-->
<!-- Painel expansível do Layout 2 -->
<script setup>
import { computed, ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { computed, ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useMenuStore } from '@/stores/menuStore'
import { useLayout } from './composables/layout'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { useMenuBadges } from '@/composables/useMenuBadges'
import { useMenuStore } from '@/stores/menuStore';
import { useLayout } from './composables/layout';
import { useEntitlementsStore } from '@/stores/entitlementsStore';
import { useMenuBadges } from '@/composables/useMenuBadges';
import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue'
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue';
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
const menuStore = useMenuStore()
const { layoutState } = useLayout()
const entitlements = useEntitlementsStore()
const menuBadges = useMenuBadges()
const router = useRouter()
const route = useRoute()
const menuStore = useMenuStore();
const { layoutState } = useLayout();
const entitlements = useEntitlementsStore();
const menuBadges = useMenuBadges();
const router = useRouter();
const route = useRoute();
function menuBadgeLabel (item) {
const key = item?.badgeKey
if (!key) return null
const val = menuBadges[key]?.value || 0
if (!val) return null
return key === 'agendaHoje' ? `${val} hoje` : String(val)
function menuBadgeLabel(item) {
const key = item?.badgeKey;
if (!key) return null;
const val = menuBadges[key]?.value || 0;
if (!val) return null;
return key === 'agendaHoje' ? `${val} hoje` : String(val);
}
// ── Seção ativa ──────────────────────────────────────────────
const currentSection = computed(() => {
const model = menuStore.model || []
return model.find(s => s.label === layoutState.railSectionKey) || null
})
const model = menuStore.model || [];
return model.find((s) => s.label === layoutState.railSectionKey) || null;
});
// Todos os grupos do menu
const allSections = computed(() => {
const model = menuStore.model || []
return model.filter(s => s.label && Array.isArray(s.items) && s.items.length)
})
const model = menuStore.model || [];
return model.filter((s) => s.label && Array.isArray(s.items) && s.items.length);
});
// "Início" = chave especial __home__
const isHome = computed(() => layoutState.railSectionKey === '__home__')
const isHome = computed(() => layoutState.railSectionKey === '__home__');
// Seções visíveis: tudo em Início, só a selecionada nos demais
const visibleSections = computed(() =>
isHome.value ? allSections.value : (currentSection.value ? [currentSection.value] : [])
)
const visibleSections = computed(() => (isHome.value ? allSections.value : currentSection.value ? [currentSection.value] : []));
const panelTitle = computed(() =>
isHome.value ? 'Início' : currentSection.value?.label || 'Menu'
)
const panelTitle = computed(() => (isHome.value ? 'Início' : currentSection.value?.label || 'Menu'));
// ── Helpers ──────────────────────────────────────────────────
function isLocked (item) {
if (!item.proBadge || !item.feature) return false
try { return !entitlements.has(item.feature) } catch { return false }
function isLocked(item) {
if (!item.proBadge || !item.feature) return false;
try {
return !entitlements.has(item.feature);
} catch {
return false;
}
}
function toPath (to) {
if (!to) return ''
if (typeof to === 'string') return to
try { return router.resolve(to).path || '' } catch { return '' }
function toPath(to) {
if (!to) return '';
if (typeof to === 'string') return to;
try {
return router.resolve(to).path || '';
} catch {
return '';
}
}
function isActive (item) {
const active = String(layoutState.activePath || route.path || '')
if (!item.to) return false
const p = toPath(item.to)
if (!p) return false
if (active === p) return true
const segments = p.split('/').filter(Boolean)
return segments.length >= 2 && active.startsWith(p + '/')
function isActive(item) {
const active = String(layoutState.activePath || route.path || '');
if (!item.to) return false;
const p = toPath(item.to);
if (!p) return false;
if (active === p) return true;
const segments = p.split('/').filter(Boolean);
return segments.length >= 2 && active.startsWith(p + '/');
}
function navigate (item) {
if (isLocked(item)) {
router.push({ name: 'upgrade', query: { feature: item.feature || '' } })
return
}
if (item.to) {
layoutState.activePath = typeof item.to === 'string' ? item.to : router.resolve(item.to).path
router.push(item.to)
}
function navigate(item) {
if (isLocked(item)) {
router.push({ name: 'upgrade', query: { feature: item.feature || '' } });
return;
}
if (item.to) {
layoutState.activePath = typeof item.to === 'string' ? item.to : router.resolve(item.to).path;
router.push(item.to);
}
}
function closePanel () {
layoutState.railPanelOpen = false
function closePanel() {
layoutState.railPanelOpen = false;
}
// ── QuickCreate (Pacientes) ───────────────────────────────
const createPopover = ref(null)
const quickDialog = ref(false)
const createPopover = ref(null);
const quickDialog = ref(false);
function openQuickCreate (event, item) {
createPopover.value?.toggle(event)
function openQuickCreate(event, item) {
createPopover.value?.toggle(event);
}
function onQuickCreate() {
quickDialog.value = true;
}
function onQuickCreate () { quickDialog.value = true }
// ── Busca (todo o menu) ──────────────────────────────────────
const query = ref('')
const showResults = ref(false)
const activeIndex = ref(-1)
const forcedOpen = ref(false)
const searchEl = ref(null)
const searchWrapEl = ref(null)
const query = ref('');
const showResults = ref(false);
const activeIndex = ref(-1);
const forcedOpen = ref(false);
const searchEl = ref(null);
const searchWrapEl = ref(null);
const RECENT_KEY = 'menu_search_recent'
const recent = ref([])
function loadRecent () {
try { recent.value = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]') } catch { recent.value = [] }
const RECENT_KEY = 'menu_search_recent';
const recent = ref([]);
function loadRecent() {
try {
recent.value = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]');
} catch {
recent.value = [];
}
}
function saveRecent (q) {
const v = String(q || '').trim()
if (!v) return
const list = [v, ...recent.value.filter(x => x !== v)].slice(0, 8)
recent.value = list
localStorage.setItem(RECENT_KEY, JSON.stringify(list))
function saveRecent(q) {
const v = String(q || '').trim();
if (!v) return;
const list = [v, ...recent.value.filter((x) => x !== v)].slice(0, 8);
recent.value = list;
localStorage.setItem(RECENT_KEY, JSON.stringify(list));
}
function clearRecent () {
recent.value = []
try { localStorage.removeItem(RECENT_KEY) } catch {}
function clearRecent() {
recent.value = [];
try {
localStorage.removeItem(RECENT_KEY);
} catch {}
}
loadRecent()
loadRecent();
watch(query, (v) => {
const hasText = !!v?.trim()
if (hasText) { forcedOpen.value = false; showResults.value = true; return }
showResults.value = forcedOpen.value
})
function clearSearch () {
query.value = ''
activeIndex.value = -1
showResults.value = false
forcedOpen.value = false
}
function norm (s) {
return String(s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').trim()
}
function isVisibleItem (it) {
const v = it?.visible
if (typeof v === 'function') return !!v()
if (v === undefined || v === null) return true
return v !== false
}
function flattenMenu (items, trail = []) {
const out = []
for (const it of (items || [])) {
if (!isVisibleItem(it)) continue
const nextTrail = [...trail, it?.label].filter(Boolean)
if (it?.to && !it?.items?.length) {
out.push({ label: it.label || it.to, to: it.to, icon: it.icon, trail: nextTrail, proBadge: !!(it.__showProBadge ?? it.proBadge), feature: it.feature || null })
const hasText = !!v?.trim();
if (hasText) {
forcedOpen.value = false;
showResults.value = true;
return;
}
if (it?.items?.length) out.push(...flattenMenu(it.items, nextTrail))
}
return out
showResults.value = forcedOpen.value;
});
function clearSearch() {
query.value = '';
activeIndex.value = -1;
showResults.value = false;
forcedOpen.value = false;
}
const allLinks = computed(() => flattenMenu(menuStore.model || []))
function norm(s) {
return String(s || '')
.toLowerCase()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.trim();
}
function isVisibleItem(it) {
const v = it?.visible;
if (typeof v === 'function') return !!v();
if (v === undefined || v === null) return true;
return v !== false;
}
function flattenMenu(items, trail = []) {
const out = [];
for (const it of items || []) {
if (!isVisibleItem(it)) continue;
const nextTrail = [...trail, it?.label].filter(Boolean);
if (it?.to && !it?.items?.length) {
out.push({ label: it.label || it.to, to: it.to, icon: it.icon, trail: nextTrail, proBadge: !!(it.__showProBadge ?? it.proBadge), feature: it.feature || null });
}
if (it?.items?.length) out.push(...flattenMenu(it.items, nextTrail));
}
return out;
}
const allLinks = computed(() => flattenMenu(menuStore.model || []));
const results = computed(() => {
const q = norm(query.value)
if (!q) return []
const wantPro = q === 'pro' || q.startsWith('pro ') || q.includes(' pro')
return allLinks.value
.filter(r => {
const hay = `${norm(r.label)} ${norm(r.trail.join(' > '))} ${norm(r.to)}`
if (hay.includes(q)) return true
if (wantPro && (r.proBadge || r.feature)) return true
return false
})
.slice(0, 12)
})
const q = norm(query.value);
if (!q) return [];
const wantPro = q === 'pro' || q.startsWith('pro ') || q.includes(' pro');
return allLinks.value
.filter((r) => {
const hay = `${norm(r.label)} ${norm(r.trail.join(' > '))} ${norm(r.to)}`;
if (hay.includes(q)) return true;
if (wantPro && (r.proBadge || r.feature)) return true;
return false;
})
.slice(0, 12);
});
watch(results, (list) => { activeIndex.value = list.length ? 0 : -1 })
watch(results, (list) => {
activeIndex.value = list.length ? 0 : -1;
});
function escapeHtml (s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;')
function escapeHtml(s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function highlight (text, q) {
const queryNorm = norm(q)
const raw = String(text || '')
if (!queryNorm) return escapeHtml(raw)
const rawNorm = norm(raw)
const idx = rawNorm.indexOf(queryNorm)
if (idx < 0) return escapeHtml(raw)
const before = escapeHtml(raw.slice(0, idx))
const mid = escapeHtml(raw.slice(idx, idx + queryNorm.length))
const after = escapeHtml(raw.slice(idx + queryNorm.length))
return `${before}<mark class="px-1 rounded bg-yellow-200/40">${mid}</mark>${after}`
function highlight(text, q) {
const queryNorm = norm(q);
const raw = String(text || '');
if (!queryNorm) return escapeHtml(raw);
const rawNorm = norm(raw);
const idx = rawNorm.indexOf(queryNorm);
if (idx < 0) return escapeHtml(raw);
const before = escapeHtml(raw.slice(0, idx));
const mid = escapeHtml(raw.slice(idx, idx + queryNorm.length));
const after = escapeHtml(raw.slice(idx + queryNorm.length));
return `${before}<mark class="px-1 rounded bg-yellow-200/40">${mid}</mark>${after}`;
}
function onSearchKeydown (e) {
if (e.key === 'Escape') { showResults.value = false; forcedOpen.value = false; return }
if (e.key === 'ArrowDown') {
e.preventDefault()
if (!results.value.length) return
showResults.value = true
activeIndex.value = (activeIndex.value + 1) % results.value.length
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
if (!results.value.length) return
showResults.value = true
activeIndex.value = (activeIndex.value - 1 + results.value.length) % results.value.length
return
}
if (e.key === 'Enter' && showResults.value && results.value.length && activeIndex.value >= 0) {
e.preventDefault()
goToResult(results.value[activeIndex.value])
}
function onSearchKeydown(e) {
if (e.key === 'Escape') {
showResults.value = false;
forcedOpen.value = false;
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!results.value.length) return;
showResults.value = true;
activeIndex.value = (activeIndex.value + 1) % results.value.length;
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (!results.value.length) return;
showResults.value = true;
activeIndex.value = (activeIndex.value - 1 + results.value.length) % results.value.length;
return;
}
if (e.key === 'Enter' && showResults.value && results.value.length && activeIndex.value >= 0) {
e.preventDefault();
goToResult(results.value[activeIndex.value]);
}
}
function onSearchFocus () {
if (!query.value?.trim()) { forcedOpen.value = true; showResults.value = true }
function onSearchFocus() {
if (!query.value?.trim()) {
forcedOpen.value = true;
showResults.value = true;
}
}
function applyRecent (q) {
query.value = q
forcedOpen.value = true
showResults.value = true
activeIndex.value = 0
nextTick(() => searchEl.value?.$el?.querySelector?.('input')?.focus?.())
function applyRecent(q) {
query.value = q;
forcedOpen.value = true;
showResults.value = true;
activeIndex.value = 0;
nextTick(() => searchEl.value?.$el?.querySelector?.('input')?.focus?.());
}
function onDocMouseDown (e) {
if (!showResults.value) return
if (!searchWrapEl.value?.contains(e.target)) { showResults.value = false; forcedOpen.value = false }
function onDocMouseDown(e) {
if (!showResults.value) return;
if (!searchWrapEl.value?.contains(e.target)) {
showResults.value = false;
forcedOpen.value = false;
}
}
onMounted(() => document.addEventListener('mousedown', onDocMouseDown))
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocMouseDown))
onMounted(() => document.addEventListener('mousedown', onDocMouseDown));
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocMouseDown));
async function goToResult (r) {
saveRecent(query.value)
query.value = ''
showResults.value = false
activeIndex.value = -1
forcedOpen.value = false
layoutState.activePath = typeof r.to === 'string' ? r.to : router.resolve(r.to).path
await router.push(r.to)
async function goToResult(r) {
saveRecent(query.value);
query.value = '';
showResults.value = false;
activeIndex.value = -1;
forcedOpen.value = false;
layoutState.activePath = typeof r.to === 'string' ? r.to : router.resolve(r.to).path;
await router.push(r.to);
}
</script>
<template>
<Transition name="panel-slide">
<aside
v-if="layoutState.railPanelOpen"
class="w-[260px] shrink-0 h-screen flex flex-col border-r border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden"
aria-label="Menu lateral"
>
<!-- Header -->
<div class="h-14 shrink-0 flex items-center justify-between px-4 border-b border-[var(--surface-border)]">
<span class="text-[0.9rem] font-bold tracking-tight text-[var(--text-color)]">
{{ panelTitle }}
</span>
<button
class="w-7 h-7 rounded-lg border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer grid place-items-center text-xs transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
aria-label="Fechar painel"
@click="closePanel"
>
<i class="pi pi-times" />
</button>
</div>
<!-- Busca no Início -->
<div v-if="isHome" ref="searchWrapEl" class="shrink-0 px-2.5 pt-2.5 border-b border-[var(--surface-border)]">
<!-- Campo -->
<div class="relative">
<div aria-hidden="true" style="position:absolute;left:-9999px;top:-9999px;width:1px;height:1px;overflow:hidden;">
<input type="email" name="email" autocomplete="username" tabindex="-1" disabled />
<input type="password" name="password" autocomplete="current-password" tabindex="-1" disabled />
</div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
ref="searchEl"
id="rp_menu_search"
name="rp_menu_search"
type="text"
inputmode="search"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
data-lpignore="true"
data-1p-ignore="true"
v-model="query"
class="w-full pr-8"
variant="filled"
@focus="onSearchFocus"
@keydown="onSearchKeydown"
/>
</IconField>
<label for="rp_menu_search">Encontrar menu...</label>
</FloatLabel>
<button
v-if="query.trim()"
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 bg-transparent border-none cursor-pointer opacity-60 hover:opacity-100 text-[var(--text-color-secondary)] text-xs grid place-items-center p-0.5"
@mousedown.prevent="clearSearch"
aria-label="Limpar busca"
>
<i class="pi pi-times" />
</button>
</div>
<!-- Recentes -->
<div
v-if="showResults && !query.trim() && recent.length"
class="mt-1.5 mb-2.5 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden"
>
<div class="flex items-center justify-between px-2.5 py-1.5 text-[1rem] opacity-65 text-[var(--text-color-secondary)]">
<span>Recentes</span>
<button type="button" class="bg-transparent border-none cursor-pointer opacity-70 hover:opacity-100 text-inherit text-[1rem]" @mousedown.prevent="clearRecent" aria-label="Limpar recentes">
<i class="pi pi-trash" />
</button>
</div>
<button
v-for="q in recent" :key="q"
class="w-full flex items-center gap-2 px-2.5 py-1.5 bg-transparent border-none cursor-pointer text-[var(--text-color)] text-[1rem] text-left transition-colors hover:bg-[var(--surface-hover)]"
type="button"
@click.stop.prevent="applyRecent(q)"
>
<i class="pi pi-history text-[0.8rem] opacity-70 shrink-0" />
<span class="flex-1">{{ q }}</span>
</button>
</div>
<!-- Resultados de busca -->
<div
v-else-if="showResults && results.length"
class="mt-1.5 mb-2.5 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden"
>
<button
v-for="(r, i) in results" :key="String(r.to)"
type="button"
@mousedown.prevent="goToResult(r)"
class="w-full flex items-center gap-2 px-2.5 py-1.5 bg-transparent border-none cursor-pointer text-[var(--text-color)] text-[1rem] text-left transition-colors"
:class="i === activeIndex ? 'bg-[var(--surface-hover)]' : 'hover:bg-[var(--surface-hover)]'"
>
<i v-if="r.icon" :class="r.icon" class="text-[0.8rem] opacity-70 shrink-0" />
<div class="flex flex-col flex-1">
<span class="font-medium leading-tight" v-html="highlight(r.label, query)" />
<small class="text-[0.68rem] opacity-60">{{ r.trail.join(' > ') }}</small>
</div>
<span v-if="r.proBadge" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
</button>
</div>
<div v-else-if="showResults && query && !results.length" class="py-2 pb-2.5 text-[1rem] opacity-60 text-[var(--text-color-secondary)]">
Nenhum item encontrado.
</div>
<div v-else class="pb-2.5" />
</div>
<!-- Nav: todo o menu -->
<nav class="flex-1 overflow-y-auto px-2 py-2.5 flex flex-col gap-0.5 [scrollbar-width:thin] [scrollbar-color:var(--surface-border)_transparent]">
<template v-for="section in visibleSections" :key="section.label">
<!-- Label da seção exibe quando mostrando múltiplas seções -->
<div
v-if="visibleSections.length > 1"
class="text-[0.62rem] font-extrabold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-55 px-2.5 pb-1.5 pt-3 first:pt-1"
>
{{ section.label }}
</div>
<template v-for="item in section.items" :key="item.to || item.label">
<!-- Sub-grupo -->
<div v-if="item.items?.length" class="flex flex-col gap-px">
<div class="text-[0.6rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-40 px-2.5 pb-1 pt-2">
{{ item.label }}
</div>
<button
v-for="child in item.items"
:key="child.to || child.label"
class="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-[9px] border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-left text-[0.83rem] font-medium transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
:class="{
'!bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] !text-[var(--primary-color)] !font-semibold': isActive(child),
'opacity-55': isLocked(child)
}"
@click="navigate(child)"
>
<i v-if="child.icon" :class="child.icon" class="text-[1rem] shrink-0 opacity-75" />
<span class="flex-1">{{ child.label }}</span>
<span v-if="isLocked(child)" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
<span v-if="menuBadgeLabel(child)" class="text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none">{{ menuBadgeLabel(child) }}</span>
</button>
<Transition name="panel-slide">
<aside v-if="layoutState.railPanelOpen" class="w-[260px] shrink-0 h-screen flex flex-col border-r border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden" aria-label="Menu lateral">
<!-- Header -->
<div class="h-14 shrink-0 flex items-center justify-between px-4 border-b border-[var(--surface-border)]">
<span class="text-[0.9rem] font-bold tracking-tight text-[var(--text-color)]">
{{ panelTitle }}
</span>
<button
class="w-7 h-7 rounded-lg border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer grid place-items-center text-xs transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
aria-label="Fechar painel"
@click="closePanel"
>
<i class="pi pi-times" />
</button>
</div>
<!-- Item folha -->
<div v-else class="flex items-center gap-1">
<button
class="flex-1 flex items-center gap-2.5 px-2.5 py-2 rounded-[9px] border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-left text-[0.83rem] font-medium transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
:class="{
'!bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] !text-[var(--primary-color)] !font-semibold': isActive(item),
'opacity-55': isLocked(item)
}"
@click="navigate(item)"
>
<i v-if="item.icon" :class="item.icon" class="text-[1rem] shrink-0 opacity-75" />
<span class="flex-1">{{ item.label }}</span>
<span v-if="isLocked(item)" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
<span v-if="menuBadgeLabel(item)" class="text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none">{{ menuBadgeLabel(item) }}</span>
</button>
<button
v-if="item.quickCreate"
class="w-6 h-6 shrink-0 rounded-md border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer grid place-items-center text-xs transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
@click.stop="openQuickCreate($event, item)"
title="Novo paciente"
>
<i class="pi pi-plus" />
</button>
<!-- Busca no Início -->
<div v-if="isHome" ref="searchWrapEl" class="shrink-0 px-2.5 pt-2.5 border-b border-[var(--surface-border)]">
<!-- Campo -->
<div class="relative">
<div aria-hidden="true" style="position: absolute; left: -9999px; top: -9999px; width: 1px; height: 1px; overflow: hidden">
<input type="email" name="email" autocomplete="username" tabindex="-1" disabled />
<input type="password" name="password" autocomplete="current-password" tabindex="-1" disabled />
</div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
ref="searchEl"
id="rp_menu_search"
name="rp_menu_search"
type="text"
inputmode="search"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
data-lpignore="true"
data-1p-ignore="true"
v-model="query"
class="w-full pr-8"
variant="filled"
@focus="onSearchFocus"
@keydown="onSearchKeydown"
/>
</IconField>
<label for="rp_menu_search">Encontrar menu...</label>
</FloatLabel>
<button
v-if="query.trim()"
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 bg-transparent border-none cursor-pointer opacity-60 hover:opacity-100 text-[var(--text-color-secondary)] text-xs grid place-items-center p-0.5"
@mousedown.prevent="clearSearch"
aria-label="Limpar busca"
>
<i class="pi pi-times" />
</button>
</div>
<!-- Recentes -->
<div v-if="showResults && !query.trim() && recent.length" class="mt-1.5 mb-2.5 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden">
<div class="flex items-center justify-between px-2.5 py-1.5 text-[1rem] opacity-65 text-[var(--text-color-secondary)]">
<span>Recentes</span>
<button type="button" class="bg-transparent border-none cursor-pointer opacity-70 hover:opacity-100 text-inherit text-[1rem]" @mousedown.prevent="clearRecent" aria-label="Limpar recentes">
<i class="pi pi-trash" />
</button>
</div>
<button
v-for="q in recent"
:key="q"
class="w-full flex items-center gap-2 px-2.5 py-1.5 bg-transparent border-none cursor-pointer text-[var(--text-color)] text-[1rem] text-left transition-colors hover:bg-[var(--surface-hover)]"
type="button"
@click.stop.prevent="applyRecent(q)"
>
<i class="pi pi-history text-[0.8rem] opacity-70 shrink-0" />
<span class="flex-1">{{ q }}</span>
</button>
</div>
<!-- Resultados de busca -->
<div v-else-if="showResults && results.length" class="mt-1.5 mb-2.5 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden">
<button
v-for="(r, i) in results"
:key="String(r.to)"
type="button"
@mousedown.prevent="goToResult(r)"
class="w-full flex items-center gap-2 px-2.5 py-1.5 bg-transparent border-none cursor-pointer text-[var(--text-color)] text-[1rem] text-left transition-colors"
:class="i === activeIndex ? 'bg-[var(--surface-hover)]' : 'hover:bg-[var(--surface-hover)]'"
>
<i v-if="r.icon" :class="r.icon" class="text-[0.8rem] opacity-70 shrink-0" />
<div class="flex flex-col flex-1">
<span class="font-medium leading-tight" v-html="highlight(r.label, query)" />
<small class="text-[0.68rem] opacity-60">{{ r.trail.join(' > ') }}</small>
</div>
<span v-if="r.proBadge" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
</button>
</div>
<div v-else-if="showResults && query && !results.length" class="py-2 pb-2.5 text-[1rem] opacity-60 text-[var(--text-color-secondary)]">Nenhum item encontrado.</div>
<div v-else class="pb-2.5" />
</div>
</template>
</template>
</nav>
<!-- Nav: todo o menu -->
<nav class="flex-1 overflow-y-auto px-2 py-2.5 flex flex-col gap-0.5 [scrollbar-width:thin] [scrollbar-color:var(--surface-border)_transparent]">
<template v-for="section in visibleSections" :key="section.label">
<!-- Label da seção exibe quando mostrando múltiplas seções -->
<div v-if="visibleSections.length > 1" class="text-[0.62rem] font-extrabold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-55 px-2.5 pb-1.5 pt-3 first:pt-1">
{{ section.label }}
</div>
<!-- PatientCreatePopover (shared) -->
<PatientCreatePopover
ref="createPopover"
@quick-create="onQuickCreate"
/>
<template v-for="item in section.items" :key="item.to || item.label">
<!-- Sub-grupo -->
<div v-if="item.items?.length" class="flex flex-col gap-px">
<div class="text-[0.6rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-40 px-2.5 pb-1 pt-2">
{{ item.label }}
</div>
<button
v-for="child in item.items"
:key="child.to || child.label"
class="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-[9px] border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-left text-[0.83rem] font-medium transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
:class="{
'!bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] !text-[var(--primary-color)] !font-semibold': isActive(child),
'opacity-55': isLocked(child)
}"
@click="navigate(child)"
>
<i v-if="child.icon" :class="child.icon" class="text-[1rem] shrink-0 opacity-75" />
<span class="flex-1">{{ child.label }}</span>
<span v-if="isLocked(child)" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
<span v-if="menuBadgeLabel(child)" class="text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none">{{ menuBadgeLabel(child) }}</span>
</button>
</div>
<!-- Cadastro Rápido Dialog -->
<ComponentCadastroRapido
v-model="quickDialog"
title="Cadastro Rápido"
table-name="patients"
name-field="nome_completo"
email-field="email_principal"
phone-field="telefone"
:extra-payload="{ status: 'Ativo' }"
@created="quickDialog = false"
/>
</aside>
</Transition>
<!-- Item folha -->
<div v-else class="flex items-center gap-1">
<button
class="flex-1 flex items-center gap-2.5 px-2.5 py-2 rounded-[9px] border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-left text-[0.83rem] font-medium transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
:class="{
'!bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] !text-[var(--primary-color)] !font-semibold': isActive(item),
'opacity-55': isLocked(item)
}"
@click="navigate(item)"
>
<i v-if="item.icon" :class="item.icon" class="text-[1rem] shrink-0 opacity-75" />
<span class="flex-1">{{ item.label }}</span>
<span v-if="isLocked(item)" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
<span v-if="menuBadgeLabel(item)" class="text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none">{{ menuBadgeLabel(item) }}</span>
</button>
<button
v-if="item.quickCreate"
class="w-6 h-6 shrink-0 rounded-md border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer grid place-items-center text-xs transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
@click.stop="openQuickCreate($event, item)"
title="Novo paciente"
>
<i class="pi pi-plus" />
</button>
</div>
</template>
</template>
</nav>
<!-- PatientCreatePopover (shared) -->
<PatientCreatePopover ref="createPopover" @quick-create="onQuickCreate" />
<!-- Cadastro Rápido Dialog -->
<ComponentCadastroRapido
v-model="quickDialog"
title="Cadastro Rápido"
table-name="patients"
name-field="nome_completo"
email-field="email_principal"
phone-field="telefone"
:extra-payload="{ status: 'Ativo' }"
@created="quickDialog = false"
/>
</aside>
</Transition>
</template>
<style scoped>
.panel-slide-enter-active,
.panel-slide-leave-active {
transition: width 0.22s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.18s ease;
overflow: hidden;
transition:
width 0.22s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.18s ease;
overflow: hidden;
}
.panel-slide-enter-from,
.panel-slide-leave-to {
width: 0 !important;
opacity: 0;
width: 0 !important;
opacity: 0;
}
</style>
</style>
File diff suppressed because it is too large Load Diff
+45 -50
View File
@@ -15,75 +15,70 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { useLayout } from '@/layout/composables/layout'
import { onBeforeUnmount, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import AppMenu from './AppMenu.vue'
import { useLayout } from '@/layout/composables/layout';
import { onBeforeUnmount, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import AppMenu from './AppMenu.vue';
const { layoutState, isDesktop, hasOpenOverlay, closeMenuOnNavigate } = useLayout()
const route = useRoute()
const sidebarRef = ref(null)
let outsideClickListener = null
const { layoutState, isDesktop, hasOpenOverlay, closeMenuOnNavigate } = useLayout();
const route = useRoute();
const sidebarRef = ref(null);
let outsideClickListener = null;
// ✅ rota mudou:
// - atualiza activePath sempre (desktop e mobile)
// - fecha menu SOMENTE no mobile (evita "sumir" no desktop / inconsistências)
watch(
() => route.path,
(newPath) => {
layoutState.activePath = newPath
closeMenuOnNavigate?.()
},
{ immediate: true }
)
() => route.path,
(newPath) => {
layoutState.activePath = newPath;
closeMenuOnNavigate?.();
},
{ immediate: true }
);
// mantém o outside click só quando overlay está aberto e estamos em desktop
watch(hasOpenOverlay, (newVal) => {
if (isDesktop()) {
if (newVal) bindOutsideClickListener()
else unbindOutsideClickListener()
}
})
if (isDesktop()) {
if (newVal) bindOutsideClickListener();
else unbindOutsideClickListener();
}
});
const bindOutsideClickListener = () => {
if (!outsideClickListener) {
outsideClickListener = (event) => {
if (isOutsideClicked(event)) {
layoutState.overlayMenuActive = false
}
}
if (!outsideClickListener) {
outsideClickListener = (event) => {
if (isOutsideClicked(event)) {
layoutState.overlayMenuActive = false;
}
};
document.addEventListener('click', outsideClickListener)
}
}
document.addEventListener('click', outsideClickListener);
}
};
const unbindOutsideClickListener = () => {
if (outsideClickListener) {
document.removeEventListener('click', outsideClickListener)
outsideClickListener = null
}
}
if (outsideClickListener) {
document.removeEventListener('click', outsideClickListener);
outsideClickListener = null;
}
};
const isOutsideClicked = (event) => {
const topbarButtonEl = document.querySelector('.layout-menu-button')
const el = sidebarRef.value
if (!el) return true
const topbarButtonEl = document.querySelector('.layout-menu-button');
const el = sidebarRef.value;
if (!el) return true;
return !(
el.isSameNode(event.target) ||
el.contains(event.target) ||
topbarButtonEl?.isSameNode(event.target) ||
topbarButtonEl?.contains(event.target)
)
}
return !(el.isSameNode(event.target) || el.contains(event.target) || topbarButtonEl?.isSameNode(event.target) || topbarButtonEl?.contains(event.target));
};
onBeforeUnmount(() => {
unbindOutsideClickListener()
})
unbindOutsideClickListener();
});
</script>
<template>
<div ref="sidebarRef" class="layout-sidebar">
<AppMenu />
</div>
</template>
<div ref="sidebarRef" class="layout-sidebar">
<AppMenu />
</div>
</template>
+323
View File
@@ -0,0 +1,323 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/layout/AppThemeBar.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, onMounted } from 'vue';
import { useLayout } from '@/layout/composables/layout';
import { useConfiguratorBar } from '@/layout/composables/useConfiguratorBar';
import { primaryColors, surfaces, presetOptions, applyThemeEngine } from '@/theme/theme.options';
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence';
const { layoutConfig, isDarkTheme, changeMenuMode, setVariant, layoutState, isMobile, effectiveVariant, effectiveMenuMode } = useLayout();
const { close } = useConfiguratorBar();
const { init: initSettings, queuePatch } = useUserSettingsPersistence();
onMounted(() => initSettings());
/* ── Offset esquerdo dinâmico ──────────────────────────────
Rail desktop : 60px (rail) + 260px (painel, se aberto)
Clássico static ativo: 20rem (sidebar)
Demais casos : 0px
───────────────────────────────────────────────────────────── */
const leftOffset = computed(() => {
if (isMobile.value) return '0px';
if (effectiveVariant.value === 'rail') {
const panelW = layoutState.railPanelOpen ? 260 : 0;
return `${60 + panelW}px`;
}
// Clássico
if (effectiveMenuMode.value === 'overlay' || layoutState.staticMenuInactive) return '0px';
return '20rem';
});
const barStyle = computed(() => ({ left: leftOffset.value }));
/* ── Cores e presets ────────────────────────────────────── */
const menuModeOptions = [
{ label: 'Static', value: 'static' },
{ label: 'Overlay', value: 'overlay' }
];
const presetModel = computed({
get: () => layoutConfig.preset,
set: (v) => {
layoutConfig.preset = v;
applyThemeEngine(layoutConfig);
queuePatch?.({ preset: v });
}
});
const menuModeModel = computed({
get: () => layoutConfig.menuMode,
set: (v) => {
changeMenuMode(v);
queuePatch?.({ menu_mode: v });
}
});
function updateColors(type, item) {
if (type === 'primary') {
layoutConfig.primary = item.name;
applyThemeEngine(layoutConfig);
queuePatch?.({ primary_color: item.name });
}
if (type === 'surface') {
layoutConfig.surface = item.name;
applyThemeEngine(layoutConfig);
queuePatch?.({ surface_color: item.name });
}
}
function handleSetVariant(v) {
setVariant(v);
queuePatch?.({ layout_variant: v });
}
</script>
<template>
<div class="theme-bar" :style="barStyle">
<div class="theme-bar__inner">
<!-- Cor principal -->
<div class="theme-bar__section">
<span class="theme-bar__label">Cor principal</span>
<div class="theme-bar__swatches">
<button
v-for="c of primaryColors"
:key="c.name"
type="button"
:title="c.name"
@click="updateColors('primary', c)"
:class="['swatch', { 'swatch--active': layoutConfig.primary === c.name }]"
:style="{ backgroundColor: c.name === 'noir' ? 'var(--text-color)' : c.palette['500'] }"
/>
</div>
</div>
<div class="theme-bar__divider" />
<!-- Surface -->
<div class="theme-bar__section">
<span class="theme-bar__label">Surface</span>
<div class="theme-bar__swatches">
<button
v-for="s of surfaces"
:key="s.name"
type="button"
:title="s.name"
@click="updateColors('surface', s)"
:class="[
'swatch',
{
'swatch--active': layoutConfig.surface
? layoutConfig.surface === s.name
: isDarkTheme
? s.name === 'zinc'
: s.name === 'slate'
}
]"
:style="{ backgroundColor: s.palette['500'] }"
/>
</div>
</div>
<div class="theme-bar__divider" />
<!-- Preset -->
<div class="theme-bar__section">
<span class="theme-bar__label">Preset</span>
<SelectButton v-model="presetModel" :options="presetOptions" :allowEmpty="false" size="small" />
</div>
<!-- Menu mode (somente layout clássico) -->
<template v-if="effectiveVariant === 'classic'">
<div class="theme-bar__divider" />
<div class="theme-bar__section">
<span class="theme-bar__label">Menu</span>
<SelectButton v-model="menuModeModel" :options="menuModeOptions" :allowEmpty="false" optionLabel="label" optionValue="value" size="small" />
</div>
</template>
<div class="theme-bar__divider" />
<!-- Layout variant -->
<div class="theme-bar__section">
<span class="theme-bar__label">Layout</span>
<div class="flex gap-2">
<button
type="button"
class="variant-btn"
:class="{ 'variant-btn--active': layoutConfig.variant === 'rail' }"
@click="handleSetVariant('rail')"
>
<i :class="layoutConfig.variant === 'rail' ? 'pi pi-check-circle' : 'pi pi-circle'" />
Rail
</button>
<button
type="button"
class="variant-btn"
:class="{ 'variant-btn--active': layoutConfig.variant === 'classic' }"
@click="handleSetVariant('classic')"
>
<i :class="layoutConfig.variant === 'classic' ? 'pi pi-check-circle' : 'pi pi-circle'" />
Clássico
</button>
</div>
</div>
<!-- Fechar -->
<button type="button" class="theme-bar__close" title="Fechar painel de tema" @click="close">
<i class="pi pi-times" />
</button>
</div>
</div>
</template>
<style scoped>
.theme-bar {
position: fixed;
bottom: 0;
right: 0;
background: var(--surface-card);
border-top: 1px solid var(--surface-border);
box-shadow: 0 -2px 16px rgba(0, 0, 0, 0.08);
z-index: 200;
padding: 0.6rem 1rem;
/* left acompanha o painel rail; transform é o slide-up/down de abrir/fechar */
transition:
left 0.22s cubic-bezier(0.22, 1, 0.36, 1),
transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.theme-bar__inner {
display: flex;
align-items: center;
gap: 0;
flex-wrap: wrap;
row-gap: 0.5rem;
min-height: 52px;
}
.theme-bar__section {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 0 0.85rem;
}
.theme-bar__label {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-color-secondary);
white-space: nowrap;
}
.theme-bar__swatches {
display: flex;
gap: 0.28rem;
flex-wrap: wrap;
max-width: 220px;
}
.theme-bar__divider {
width: 1px;
height: 44px;
background: var(--surface-border);
flex-shrink: 0;
align-self: center;
}
/* ── Swatches ─────────────────────────────────────────── */
.swatch {
width: 1.05rem;
height: 1.05rem;
border-radius: 50%;
border: none;
padding: 0;
cursor: pointer;
outline: none;
outline-offset: 2px;
transition: transform 0.12s;
flex-shrink: 0;
}
.swatch:hover {
transform: scale(1.2);
}
.swatch--active {
outline: 2px solid var(--primary-color);
}
/* ── Variant buttons ──────────────────────────────────── */
.variant-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.28rem 0.6rem;
border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-ground);
color: var(--text-color-secondary);
font-size: 0.78rem;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.variant-btn:hover {
color: var(--text-color);
border-color: color-mix(in srgb, var(--primary-color) 60%, transparent);
}
.variant-btn--active {
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
color: var(--primary-color);
border-color: var(--primary-color);
}
/* ── Fechar ───────────────────────────────────────────── */
.theme-bar__close {
margin-left: auto;
padding-left: 0.85rem;
width: 2rem;
height: 2rem;
border-radius: 50%;
border: none;
background: transparent;
color: var(--text-color-secondary);
display: grid;
place-items: center;
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.theme-bar__close:hover {
background: var(--surface-ground);
color: var(--text-color);
}
/* ── Mobile: wrap, sem dividers verticais ─────────────── */
@media (max-width: 768px) {
.theme-bar__divider {
display: none;
}
.theme-bar__section {
padding: 0 0.5rem;
}
.theme-bar__swatches {
max-width: 100%;
}
}
</style>
+570 -648
View File
File diff suppressed because it is too large Load Diff
+220 -220
View File
@@ -15,259 +15,259 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { computed, ref, onMounted, onBeforeUnmount } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const route = useRoute()
const router = useRouter()
const route = useRoute();
const router = useRouter();
const showMenu = ref(false)
const showMenu = ref(false);
// ── Hero sticky ────────────────────────────────────────────
const headerEl = ref(null)
const headerSentinelRef = ref(null)
const headerStuck = ref(false)
let _observer = null
const headerEl = ref(null);
const headerSentinelRef = ref(null);
const headerStuck = ref(false);
let _observer = null;
const secoes = [
{
key: 'agenda',
label: 'Agenda',
desc: 'Horários semanais, exceções, duração e intervalo padrão.',
icon: 'pi pi-calendar',
to: '/configuracoes/agenda',
tags: ['Horários', 'Exceções', 'Duração']
},
{
key: 'bloqueios',
label: 'Bloqueios',
desc: 'Feriados nacionais, municipais e períodos bloqueados.',
icon: 'pi pi-ban',
to: '/configuracoes/bloqueios',
tags: ['Feriados', 'Períodos', 'Recorrentes']
},
{
key: 'agendador',
label: 'Agendador Online',
desc: 'Link público para pacientes solicitarem horários.',
icon: 'pi pi-calendar-clock',
to: '/configuracoes/agendador',
tags: ['PRO', 'Link', 'Pix', 'LGPD']
},
{
key: 'pagamento',
label: 'Pagamento',
desc: 'Formas de pagamento: Pix, depósito, dinheiro, cartão, convênio.',
icon: 'pi pi-wallet',
to: '/configuracoes/pagamento',
tags: ['Pix', 'TED', 'Cartão', 'Convênio']
},
{
key: 'precificacao',
label: 'Precificação',
desc: 'Valor padrão da sessão e preços por tipo de compromisso.',
icon: 'pi pi-tag',
to: '/configuracoes/precificacao',
tags: ['Valores', 'Sessão', 'Compromisso']
},
{
key: 'descontos',
label: 'Descontos por Paciente',
desc: 'Descontos recorrentes aplicados automaticamente.',
icon: 'pi pi-percentage',
to: '/configuracoes/descontos',
tags: ['Desconto', 'Paciente', 'Automático']
},
{
key: 'excecoes-financeiras',
label: 'Exceções Financeiras',
desc: 'O que cobrar em faltas, cancelamentos e situações excepcionais.',
icon: 'pi pi-exclamation-triangle',
to: '/configuracoes/excecoes-financeiras',
tags: ['Falta', 'Cancelamento', 'Cobrança']
},
{
key: 'convenios',
label: 'Convênios',
desc: 'Cadastre os convênios que você atende e seus valores.',
icon: 'pi pi-id-card',
to: '/configuracoes/convenios',
tags: ['Convênio', 'Plano de Saúde', 'Tabela']
},
{
key: 'empresa',
label: 'Minha Empresa',
desc: 'CNPJ, endereço, logomarca e redes sociais.',
icon: 'pi pi-building',
to: '/configuracoes/empresa',
tags: ['CNPJ', 'Endereço', 'Logo']
},
{
key: 'email-templates',
label: 'Templates de E-mail',
desc: 'Personalize os e-mails enviados aos pacientes.',
icon: 'pi pi-envelope',
to: '/configuracoes/email-templates',
tags: ['E-mail', 'Notificações', 'Personalizar']
},
]
{
key: 'agenda',
label: 'Agenda',
desc: 'Horários semanais, exceções, duração e intervalo padrão.',
icon: 'pi pi-calendar',
to: '/configuracoes/agenda',
tags: ['Horários', 'Exceções', 'Duração']
},
{
key: 'bloqueios',
label: 'Bloqueios',
desc: 'Feriados nacionais, municipais e períodos bloqueados.',
icon: 'pi pi-ban',
to: '/configuracoes/bloqueios',
tags: ['Feriados', 'Períodos', 'Recorrentes']
},
{
key: 'agendador',
label: 'Agendador Online',
desc: 'Link público para pacientes solicitarem horários.',
icon: 'pi pi-calendar-clock',
to: '/configuracoes/agendador',
tags: ['PRO', 'Link', 'Pix', 'LGPD']
},
{
key: 'pagamento',
label: 'Pagamento',
desc: 'Formas de pagamento: Pix, depósito, dinheiro, cartão, convênio.',
icon: 'pi pi-wallet',
to: '/configuracoes/pagamento',
tags: ['Pix', 'TED', 'Cartão', 'Convênio']
},
{
key: 'precificacao',
label: 'Precificação',
desc: 'Valor padrão da sessão e preços por tipo de compromisso.',
icon: 'pi pi-tag',
to: '/configuracoes/precificacao',
tags: ['Valores', 'Sessão', 'Compromisso']
},
{
key: 'descontos',
label: 'Descontos por Paciente',
desc: 'Descontos recorrentes aplicados automaticamente.',
icon: 'pi pi-percentage',
to: '/configuracoes/descontos',
tags: ['Desconto', 'Paciente', 'Automático']
},
{
key: 'excecoes-financeiras',
label: 'Exceções Financeiras',
desc: 'O que cobrar em faltas, cancelamentos e situações excepcionais.',
icon: 'pi pi-exclamation-triangle',
to: '/configuracoes/excecoes-financeiras',
tags: ['Falta', 'Cancelamento', 'Cobrança']
},
{
key: 'convenios',
label: 'Convênios',
desc: 'Cadastre os convênios que você atende e seus valores.',
icon: 'pi pi-id-card',
to: '/configuracoes/convenios',
tags: ['Convênio', 'Plano de Saúde', 'Tabela']
},
{
key: 'empresa',
label: 'Minha Empresa',
desc: 'CNPJ, endereço, logomarca e redes sociais.',
icon: 'pi pi-building',
to: '/configuracoes/empresa',
tags: ['CNPJ', 'Endereço', 'Logo']
},
{
key: 'email-templates',
label: 'Templates de E-mail',
desc: 'Personalize os e-mails enviados aos pacientes.',
icon: 'pi pi-envelope',
to: '/configuracoes/email-templates',
tags: ['E-mail', 'Notificações', 'Personalizar']
},
{
key: 'whatsapp',
label: 'WhatsApp',
desc: 'Configure a integração WhatsApp e personalize as mensagens.',
icon: 'pi pi-whatsapp',
to: '/configuracoes/whatsapp',
tags: ['WhatsApp', 'Mensagens', 'Notificações']
},
{
key: 'sms',
label: 'SMS',
desc: 'Gerencie créditos SMS e personalize as mensagens enviadas.',
icon: 'pi pi-comment',
to: '/configuracoes/sms',
tags: ['SMS', 'Créditos', 'Mensagens']
},
{
key: 'recursos-extras',
label: 'Recursos Extras',
desc: 'Amplíe as funcionalidades com recursos adicionais e créditos.',
icon: 'pi pi-box',
to: '/configuracoes/recursos-extras',
tags: ['Add-ons', 'Créditos', 'Extra']
}
];
const activeTo = computed(() => {
const p = route.path || ''
const hit = [...secoes]
.sort((a, b) => b.to.length - a.to.length)
.find(s => p === s.to || p.startsWith(s.to + '/'))
return hit?.to || '/configuracoes/agenda'
})
const p = route.path || '';
const hit = [...secoes].sort((a, b) => b.to.length - a.to.length).find((s) => p === s.to || p.startsWith(s.to + '/'));
return hit?.to || '/configuracoes/agenda';
});
const activeSecao = computed(() => secoes.find(s => s.to === activeTo.value))
const activeSecao = computed(() => secoes.find((s) => s.to === activeTo.value));
function ir(to) {
if (!to) return
if (route.path !== to) router.push(to)
if (!to) return;
if (route.path !== to) router.push(to);
}
onMounted(() => {
requestAnimationFrame(() => {
showMenu.value = true
})
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
_observer = new IntersectionObserver(
([entry]) => { headerStuck.value = !entry.isIntersecting },
{ threshold: 0, rootMargin }
)
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
})
requestAnimationFrame(() => {
showMenu.value = true;
});
onBeforeUnmount(() => { _observer?.disconnect() })
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`;
_observer = new IntersectionObserver(
([entry]) => {
headerStuck.value = !entry.isIntersecting;
},
{ threshold: 0, rootMargin }
);
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value);
});
onBeforeUnmount(() => {
_observer?.disconnect();
});
</script>
<template>
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
<!-- Hero compacto -->
<div
ref="headerEl"
class="sticky top-[var(--layout-sticky-top,56px)] z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] px-3 py-2.5 mx-3 md:mx-4 mb-3"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
>
<!-- Blobs decorativos -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-emerald-300/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-indigo-500/[0.09]" />
</div>
<div class="relative z-[1] flex items-center gap-3">
<!-- Brand -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/[0.12] text-[var(--p-primary-500,#6366f1)]">
<i class="pi pi-cog text-base" />
</div>
<div class="min-w-0 lg:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Configurações</div>
<div class="text-xs text-[var(--text-color-secondary)]">
<span v-if="activeSecao">
<i :class="activeSecao.icon" class="text-xs mr-1 opacity-60" />{{ activeSecao.label }}
</span>
<span v-else>Configurações gerais</span>
</div>
</div>
</div>
<!-- Ações -->
<div class="flex items-center gap-2 ml-auto">
<Button icon="pi pi-arrow-left" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Voltar'" @click="router.back()" />
</div>
</div>
</div>
<!-- Cards de seção (stats row) -->
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
<button
v-for="s in secoes"
:key="s.key"
class="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] cursor-pointer whitespace-nowrap transition-[border-color,background,box-shadow] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="
activeTo === s.to
? 'cfg-sec-card--active'
: 'hover:border-indigo-500/40'
"
@click="ir(s.to)"
>
<i
:class="[s.icon, activeTo === s.to ? 'text-[var(--primary-color,#6366f1)]' : 'text-[var(--text-color-secondary)] opacity-75']"
class="text-[0.78rem]"
/>
<span
class="text-[0.78rem] font-semibold"
:class="activeTo === s.to ? 'text-[var(--primary-color,#6366f1)]' : 'text-[var(--text-color)]'"
>{{ s.label }}</span>
</button>
</div>
<!-- Layout: sidebar + conteúdo -->
<div class="flex flex-col xl:flex-row gap-3 px-3 md:px-4 pb-5 items-start">
<!-- Sidebar (oculto no mobile) -->
<!-- Hero compacto -->
<div
class="hidden xl:flex flex-col gap-1 w-[300px] shrink-0"
style="position: sticky; top: calc(var(--layout-sticky-top, 56px) + 58px); align-self: flex-start;"
ref="headerEl"
class="sticky top-[var(--layout-sticky-top,56px)] z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] px-3 py-2.5 mx-3 md:mx-4 mb-3"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
>
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] overflow-hidden">
<!-- Cabeçalho -->
<div class="flex items-center gap-1.5 px-3.5 py-2.5 border-b border-[var(--surface-border)] text-[0.7rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-65">
<i class="pi pi-cog" />
<span>Seções</span>
<!-- Blobs decorativos -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-emerald-300/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-indigo-500/[0.09]" />
</div>
<!-- Itens -->
<TransitionGroup name="menu" tag="div" class="flex flex-col gap-0.5">
<button
v-for="(s, i) in showMenu ? secoes : []"
:key="s.key"
:style="{ transitionDelay: `${i * 50}ms` }"
class="flex items-center gap-2.5 px-3.5 py-2.5 border-b last:border-b-0 bg-transparent cursor-pointer w-full text-left transition-colors duration-[120ms] hover:bg-[var(--surface-hover)]"
:class="activeTo === s.to ? 'cfg-nav-item--active border border-[var(--primary-color,#6366f1)] last:border-b-1 last:rounded-bl-[6px] last:rounded-br-[6px]' : ''"
@click="ir(s.to)"
>
<i
:class="[s.icon, activeTo === s.to ? 'text-[var(--primary-color,#6366f1)] opacity-100' : 'text-[var(--text-color-secondary)] opacity-60']"
class="text-[0.85rem] flex-shrink-0 w-4 text-center"
/>
<div class="flex-1 min-w-0 flex flex-col gap-px ">
<span
class="text-[0.90rem] font-semibold truncate"
:class="activeTo === s.to ? 'text-[var(--primary-color,#6366f1)]' : 'text-[var(--text-color)]'"
>{{ s.label }}</span>
<span class="text-[0.88rem] text-[var(--text-color-secondary)] opacity-70">{{ s.desc }}</span>
<div class="relative z-[1] flex items-center gap-3">
<!-- Brand -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/[0.12] text-[var(--p-primary-500,#6366f1)]">
<i class="pi pi-cog text-base" />
</div>
<div class="min-w-0 lg:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Configurações</div>
<div class="text-xs text-[var(--text-color-secondary)]">
<span v-if="activeSecao"> <i :class="activeSecao.icon" class="text-xs mr-1 opacity-60" />{{ activeSecao.label }} </span>
<span v-else>Configurações gerais</span>
</div>
</div>
</div>
<i
class="pi pi-chevron-right text-[0.6rem] flex-shrink-0"
:class="activeTo === s.to ? 'text-[var(--primary-color,#6366f1)] opacity-60' : 'text-[var(--text-color-secondary)] opacity-30'"
/>
</button>
</TransitionGroup>
</div>
<!-- Ações -->
<div class="flex items-center gap-2 ml-auto">
<Button icon="pi pi-arrow-left" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Voltar'" @click="router.back()" />
</div>
</div>
</div>
<!-- Conteúdo da seção -->
<div class="flex-1 min-w-0 w-full">
<router-view />
<!-- Cards de seção (stats row) -->
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
<button
v-for="s in secoes"
:key="s.key"
class="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] cursor-pointer whitespace-nowrap transition-[border-color,background,box-shadow] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="activeTo === s.to ? 'cfg-sec-card--active' : 'hover:border-indigo-500/40'"
@click="ir(s.to)"
>
<i :class="[s.icon, activeTo === s.to ? 'text-[var(--primary-color,#6366f1)]' : 'text-[var(--text-color-secondary)] opacity-75']" class="text-[0.78rem]" />
<span class="text-[0.78rem] font-semibold" :class="activeTo === s.to ? 'text-[var(--primary-color,#6366f1)]' : 'text-[var(--text-color)]'">{{ s.label }}</span>
</button>
</div>
</div>
<!-- Layout: sidebar + conteúdo -->
<div class="flex flex-col xl:flex-row gap-3 px-3 md:px-4 pb-5 items-start">
<!-- Sidebar (oculto no mobile) -->
<div class="hidden xl:flex flex-col gap-1 w-[300px] shrink-0" style="position: sticky; top: calc(var(--layout-sticky-top, 56px) + 58px); align-self: flex-start">
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] overflow-hidden">
<!-- Cabeçalho -->
<div class="flex items-center gap-1.5 px-3.5 py-2.5 border-b border-[var(--surface-border)] text-[0.7rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-65">
<i class="pi pi-cog" />
<span>Seções</span>
</div>
<!-- Itens -->
<TransitionGroup name="menu" tag="div" class="flex flex-col gap-0.5">
<button
v-for="(s, i) in showMenu ? secoes : []"
:key="s.key"
:style="{ transitionDelay: `${i * 50}ms` }"
class="flex items-center gap-2.5 px-3.5 py-2.5 border-b last:border-b-0 bg-transparent cursor-pointer w-full text-left transition-colors duration-[120ms] hover:bg-[var(--surface-hover)]"
:class="activeTo === s.to ? 'cfg-nav-item--active border border-[var(--primary-color,#6366f1)] last:border-b-1 last:rounded-bl-[6px] last:rounded-br-[6px]' : ''"
@click="ir(s.to)"
>
<i :class="[s.icon, activeTo === s.to ? 'text-[var(--primary-color,#6366f1)] opacity-100' : 'text-[var(--text-color-secondary)] opacity-60']" class="text-[0.85rem] flex-shrink-0 w-4 text-center" />
<div class="flex-1 min-w-0 flex flex-col gap-px">
<span class="text-[0.90rem] font-semibold truncate" :class="activeTo === s.to ? 'text-[var(--primary-color,#6366f1)]' : 'text-[var(--text-color)]'">{{ s.label }}</span>
<span class="text-[0.88rem] text-[var(--text-color-secondary)] opacity-70">{{ s.desc }}</span>
</div>
<i class="pi pi-chevron-right text-[0.6rem] flex-shrink-0" :class="activeTo === s.to ? 'text-[var(--primary-color,#6366f1)] opacity-60' : 'text-[var(--text-color-secondary)] opacity-30'" />
</button>
</TransitionGroup>
</div>
</div>
<!-- Conteúdo da seção -->
<div class="flex-1 min-w-0 w-full">
<router-view />
</div>
</div>
</template>
<style scoped>
.cfg-sec-card--active {
border-color: var(--primary-color, #6366f1);
background: color-mix(in srgb, var(--primary-color) 8%, var(--surface-card));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 12%, transparent);
border-color: var(--primary-color, #6366f1);
background: color-mix(in srgb, var(--primary-color) 8%, var(--surface-card));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 12%, transparent);
}
.cfg-nav-item--active {
background: color-mix(in srgb, var(--primary-color) 6%, var(--surface-card));
background: color-mix(in srgb, var(--primary-color) 6%, var(--surface-card));
}
</style>
</style>
+3 -3
View File
@@ -14,7 +14,7 @@
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template><AppLayout area="admin" /></template>
<script setup>
import AppLayout from '../AppLayout.vue'
</script>
import AppLayout from '../AppLayout.vue';
</script>
<template><AppLayout area="admin" /></template>
+3 -3
View File
@@ -14,7 +14,7 @@
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template><AppLayout area="portal" /></template>
<script setup>
import AppLayout from '../AppLayout.vue'
</script>
import AppLayout from '../AppLayout.vue';
</script>
<template><AppLayout area="portal" /></template>
+3 -3
View File
@@ -14,7 +14,7 @@
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template><AppLayout area="therapist" /></template>
<script setup>
import AppLayout from '../AppLayout.vue'
</script>
import AppLayout from '../AppLayout.vue';
</script>
<template><AppLayout area="therapist" /></template>
+161 -152
View File
@@ -14,44 +14,47 @@
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { computed, reactive, ref, onMounted, onBeforeUnmount } from 'vue'
import { computed, reactive, ref, onMounted, onBeforeUnmount } from 'vue';
// Espelha --breakpoint-xl de src/assets/tailwind.css
const BREAKPOINT_XL = 1280;
// ── resolve variant salvo no localStorage ───────────────────
function _loadVariant () {
try {
const v = localStorage.getItem('layout_variant')
if (v === 'rail' || v === 'classic') return v
} catch {}
return 'rail'
function _loadVariant() {
try {
const v = localStorage.getItem('layout_variant');
if (v === 'rail' || v === 'classic') return v;
} catch {}
return 'rail';
}
const layoutConfig = reactive({
preset: 'Aura',
primary: 'emerald',
surface: null,
darkTheme: false,
menuMode: 'static',
variant: _loadVariant() // 'classic' | 'rail'
})
preset: 'Aura',
primary: 'emerald',
surface: null,
darkTheme: false,
menuMode: 'static',
variant: _loadVariant() // 'classic' | 'rail'
});
const layoutState = reactive({
staticMenuInactive: false,
overlayMenuActive: false,
mobileMenuActive: false,
profileSidebarVisible: false,
configSidebarVisible: false,
sidebarExpanded: false,
menuHoverActive: false,
anchored: false,
activeMenuItem: null,
activePath: null,
// ── Layout 2 (rail) ─────────────────────────────────────
railSectionKey: null, // qual seção está ativa no rail
railPanelOpen: false, // painel lateral expandido
// ── variant dirty: true quando o usuário mudou o variant
// mas ainda não salvou — impede que loadUserSettings reverta
_variantDirty: false
})
staticMenuInactive: false,
overlayMenuActive: false,
mobileMenuActive: false,
profileSidebarVisible: false,
configSidebarVisible: false,
sidebarExpanded: false,
menuHoverActive: false,
anchored: false,
activeMenuItem: null,
activePath: null,
// ── Layout 2 (rail) ─────────────────────────────────────
railSectionKey: null, // qual seção está ativa no rail
railPanelOpen: false, // painel lateral expandido
// ── variant dirty: true quando o usuário mudou o variant
// mas ainda não salvou — impede que loadUserSettings reverta
_variantDirty: false
});
/**
* ✅ Fonte da verdade do dark:
@@ -62,150 +65,156 @@ const layoutState = reactive({
* usa o composable em páginas/Topbar/Configurator. Se não sincronizar,
* isDarkTheme pode ficar "mentindo".
*/
let _syncedDarkFromDomOnce = false
function syncDarkFromDomOnce () {
if (_syncedDarkFromDomOnce) return
_syncedDarkFromDomOnce = true
try {
layoutConfig.darkTheme = document.documentElement.classList.contains('app-dark')
} catch {}
let _syncedDarkFromDomOnce = false;
function syncDarkFromDomOnce() {
if (_syncedDarkFromDomOnce) return;
_syncedDarkFromDomOnce = true;
try {
layoutConfig.darkTheme = document.documentElement.classList.contains('app-dark');
} catch {}
}
// ── reactive mobile state (atualiza no resize) ───────────────
const _isMobileRef = ref(typeof window !== 'undefined' ? window.innerWidth <= 1200 : false)
const _isMobileRef = ref(typeof window !== 'undefined' ? window.innerWidth <= BREAKPOINT_XL : false);
if (typeof window !== 'undefined') {
const _onResize = () => { _isMobileRef.value = window.innerWidth <= 1200 }
window.addEventListener('resize', _onResize, { passive: true })
const _onResize = () => {
_isMobileRef.value = window.innerWidth <= BREAKPOINT_XL;
};
window.addEventListener('resize', _onResize, { passive: true });
}
export function useLayout () {
// ✅ garante coerência sempre que alguém usar useLayout()
syncDarkFromDomOnce()
export function useLayout() {
// ✅ garante coerência sempre que alguém usar useLayout()
syncDarkFromDomOnce();
const executeDarkModeToggle = () => {
layoutConfig.darkTheme = !layoutConfig.darkTheme
const executeDarkModeToggle = () => {
layoutConfig.darkTheme = !layoutConfig.darkTheme;
// ✅ garante consistência (não depende do estado anterior do DOM)
document.documentElement.classList.toggle('app-dark', layoutConfig.darkTheme)
}
// ✅ garante consistência (não depende do estado anterior do DOM)
document.documentElement.classList.toggle('app-dark', layoutConfig.darkTheme);
};
const toggleDarkMode = () => {
if (!document.startViewTransition) {
executeDarkModeToggle()
return
}
const toggleDarkMode = () => {
if (!document.startViewTransition) {
executeDarkModeToggle();
return;
}
// ✅ não usa "event" (undefined) e mantém transição suave quando suportado
document.startViewTransition(() => executeDarkModeToggle())
}
// ✅ não usa "event" (undefined) e mantém transição suave quando suportado
document.startViewTransition(() => executeDarkModeToggle());
};
const isDesktop = () => window.innerWidth > 991
const isDesktop = () => window.innerWidth > BREAKPOINT_XL;
// breakpoint do botão hamburguer no Rail (≤ 1200px)
const isRailMobile = () => window.innerWidth <= 1200
// breakpoint do botão hamburguer no Rail (≤ xl)
const isRailMobile = () => window.innerWidth <= BREAKPOINT_XL;
const toggleMenu = () => {
// No Rail, em desktop, o botão hamburguer controla a sidebar mobile do rail
if (layoutConfig.variant === 'rail' && !_isMobileRef.value) {
layoutState.mobileMenuActive = !layoutState.mobileMenuActive
return
}
const toggleMenu = () => {
// No Rail, em desktop, o botão hamburguer controla a sidebar mobile do rail
if (layoutConfig.variant === 'rail' && !_isMobileRef.value) {
layoutState.mobileMenuActive = !layoutState.mobileMenuActive;
return;
}
// Layout clássico (ou mobile com qualquer variant) — comportamento original
if (isDesktop()) {
if (layoutConfig.menuMode === 'static') {
layoutState.staticMenuInactive = !layoutState.staticMenuInactive
}
if (layoutConfig.menuMode === 'overlay') {
layoutState.overlayMenuActive = !layoutState.overlayMenuActive
}
} else {
layoutState.mobileMenuActive = !layoutState.mobileMenuActive
}
}
// Layout clássico — usa effectiveMenuMode para decidir o estado correto
if (isDesktop()) {
if (effectiveMenuMode.value === 'static') {
layoutState.staticMenuInactive = !layoutState.staticMenuInactive;
}
if (effectiveMenuMode.value === 'overlay') {
layoutState.overlayMenuActive = !layoutState.overlayMenuActive;
}
} else {
// ≤ xl: effectiveMenuMode sempre 'overlay' — usa overlayMenuActive para
// parear corretamente com a classe layout-overlay do containerClass
layoutState.overlayMenuActive = !layoutState.overlayMenuActive;
}
};
const toggleConfigSidebar = () => {
layoutState.configSidebarVisible = !layoutState.configSidebarVisible
}
const toggleConfigSidebar = () => {
layoutState.configSidebarVisible = !layoutState.configSidebarVisible;
};
const hideMobileMenu = () => {
layoutState.mobileMenuActive = false
layoutState.overlayMenuActive = false
layoutState.menuHoverActive = false
}
const hideMobileMenu = () => {
layoutState.mobileMenuActive = false;
layoutState.overlayMenuActive = false;
layoutState.menuHoverActive = false;
};
// ✅ use isso ao navegar: mantém menu aberto no desktop, fecha só no mobile
const closeMenuOnNavigate = () => {
if (!isDesktop()) {
layoutState.mobileMenuActive = false
layoutState.overlayMenuActive = false
layoutState.menuHoverActive = false
}
}
// ✅ use isso ao navegar: mantém menu aberto no desktop, fecha só no mobile
const closeMenuOnNavigate = () => {
if (!isDesktop()) {
layoutState.mobileMenuActive = false;
layoutState.overlayMenuActive = false;
layoutState.menuHoverActive = false;
}
};
/**
* ✅ aceita:
* - changeMenuMode({ value: 'static' })
* - changeMenuMode('static')
*
* Motivo: você chama isso de lugares diferentes (Topbar, Configurator, Profile).
*/
const changeMenuMode = (eventOrValue) => {
const nextMode = typeof eventOrValue === 'string'
? eventOrValue
: eventOrValue?.value
/**
* ✅ aceita:
* - changeMenuMode({ value: 'static' })
* - changeMenuMode('static')
*
* Motivo: você chama isso de lugares diferentes (Topbar, Configurator, Profile).
*/
const changeMenuMode = (eventOrValue) => {
const nextMode = typeof eventOrValue === 'string' ? eventOrValue : eventOrValue?.value;
// ✅ não deixa setar undefined / vazio
if (!nextMode) return
// ✅ não deixa setar undefined / vazio
if (!nextMode) return;
layoutConfig.menuMode = nextMode
layoutConfig.menuMode = nextMode;
// ✅ reset consistente (evita drift quando alterna overlay/static)
layoutState.staticMenuInactive = false
layoutState.overlayMenuActive = false
layoutState.mobileMenuActive = false
layoutState.sidebarExpanded = false
layoutState.menuHoverActive = false
layoutState.anchored = false
}
// ✅ reset consistente (evita drift quando alterna overlay/static)
layoutState.staticMenuInactive = false;
layoutState.overlayMenuActive = false;
layoutState.mobileMenuActive = false;
layoutState.sidebarExpanded = false;
layoutState.menuHoverActive = false;
layoutState.anchored = false;
};
const setVariant = (v, { fromUser = true } = {}) => {
if (v !== 'classic' && v !== 'rail') return
layoutConfig.variant = v
try { localStorage.setItem('layout_variant', v) } catch {}
// reset rail state ao trocar
layoutState.railSectionKey = null
layoutState.railPanelOpen = false
// marca que o usuário fez uma escolha explícita (não restauração do DB)
if (fromUser) layoutState._variantDirty = true
}
const setVariant = (v, { fromUser = true } = {}) => {
if (v !== 'classic' && v !== 'rail') return;
layoutConfig.variant = v;
try {
localStorage.setItem('layout_variant', v);
} catch {}
// reset rail state ao trocar
layoutState.railSectionKey = null;
layoutState.railPanelOpen = false;
// marca que o usuário fez uma escolha explícita (não restauração do DB)
if (fromUser) layoutState._variantDirty = true;
};
const isDarkTheme = computed(() => layoutConfig.darkTheme)
const hasOpenOverlay = computed(() => layoutState.overlayMenuActive)
const isDarkTheme = computed(() => layoutConfig.darkTheme);
const hasOpenOverlay = computed(() => layoutState.overlayMenuActive);
// ── Em mobile (≤ 1200px) sempre usa o layout clássico, ───────
// independente de layoutConfig.variant
const isMobile = computed(() => _isMobileRef.value)
const effectiveVariant = computed(() =>
_isMobileRef.value ? 'classic' : layoutConfig.variant
)
// ── Em mobile (≤ xl / 1280px) sempre usa o layout clássico, ───────
// independente de layoutConfig.variant
const isMobile = computed(() => _isMobileRef.value);
const effectiveVariant = computed(() => (_isMobileRef.value ? 'classic' : layoutConfig.variant));
return {
layoutConfig,
layoutState,
isDarkTheme,
toggleDarkMode,
toggleConfigSidebar,
toggleMenu,
hideMobileMenu,
closeMenuOnNavigate,
changeMenuMode,
setVariant,
isDesktop,
isRailMobile,
isMobile,
effectiveVariant,
hasOpenOverlay
}
}
// ── Em mobile (≤ xl) força overlay, sem alterar o valor salvo ───────
const effectiveMenuMode = computed(() => (_isMobileRef.value ? 'overlay' : layoutConfig.menuMode));
return {
layoutConfig,
layoutState,
isDarkTheme,
toggleDarkMode,
toggleConfigSidebar,
toggleMenu,
hideMobileMenu,
closeMenuOnNavigate,
changeMenuMode,
setVariant,
isDesktop,
isRailMobile,
isMobile,
effectiveVariant,
effectiveMenuMode,
hasOpenOverlay
};
}
@@ -0,0 +1,32 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/layout/composables/useConfiguratorBar.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
// Singleton — mesmo estado em qualquer componente que importar
const open = ref(false);
export function useConfiguratorBar() {
return {
open,
toggle: () => {
open.value = !open.value;
},
close: () => {
open.value = false;
}
};
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,279 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/layout/configuracoes/ConfiguracoesCanaisPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const router = useRouter();
const toast = useToast();
const tenantStore = useTenantStore();
const userId = ref(null);
const tenantId = ref(null);
const loading = ref(true);
// ── WhatsApp ──────────────────────────────────────────────────
const whatsapp = ref({
configured: false,
status: null, // 'open' | 'close' | 'connecting' | null
checking: false,
credentials: null
});
// ── SMS ───────────────────────────────────────────────────────
const sms = ref({
hasCredits: false,
balance: 0,
isActive: false
});
// ── Email ─────────────────────────────────────────────────────
const email = ref({
templatesCount: 0
});
// ── Computed status cards ─────────────────────────────────────
const channels = computed(() => [
{
key: 'whatsapp',
label: 'WhatsApp',
icon: 'pi pi-whatsapp',
description: 'Envie lembretes e confirmações via WhatsApp automaticamente.',
route: '/configuracoes/whatsapp',
tag: whatsappTag.value,
details: whatsappDetails.value
},
{
key: 'sms',
label: 'SMS',
icon: 'pi pi-comment',
description: 'Envie SMS para pacientes com créditos pré-pagos.',
route: '/configuracoes/sms',
tag: smsTag.value,
details: smsDetails.value
},
{
key: 'email',
label: 'E-mail',
icon: 'pi pi-envelope',
description: 'Personalize os e-mails enviados automaticamente aos pacientes.',
route: '/configuracoes/email-templates',
tag: emailTag.value,
details: emailDetails.value
}
]);
const whatsappTag = computed(() => {
if (whatsapp.value.checking) return { label: 'Verificando...', severity: 'secondary', icon: 'pi pi-spin pi-spinner' };
if (!whatsapp.value.configured) return { label: 'Não configurado', severity: 'secondary', icon: 'pi pi-info-circle' };
switch (whatsapp.value.status) {
case 'open':
return { label: 'Conectado', severity: 'success', icon: 'pi pi-check-circle' };
case 'connecting':
return { label: 'Conectando...', severity: 'warn', icon: 'pi pi-spin pi-spinner' };
default:
return { label: 'Desconectado', severity: 'danger', icon: 'pi pi-times-circle' };
}
});
const whatsappDetails = computed(() => {
if (!whatsapp.value.configured) return 'Configure as credenciais da Evolution API para conectar.';
if (whatsapp.value.status === 'open') return 'Canal ativo e enviando mensagens.';
return 'Canal configurado mas desconectado. Reconecte pelo QR Code.';
});
const smsTag = computed(() => {
if (!sms.value.hasCredits) return { label: 'Sem créditos', severity: 'secondary', icon: 'pi pi-info-circle' };
if (sms.value.balance <= 0) return { label: 'Sem saldo', severity: 'danger', icon: 'pi pi-times-circle' };
if (sms.value.balance <= 10) return { label: `${sms.value.balance} créditos`, severity: 'warn', icon: 'pi pi-exclamation-triangle' };
return { label: `${sms.value.balance} créditos`, severity: 'success', icon: 'pi pi-check-circle' };
});
const smsDetails = computed(() => {
if (!sms.value.hasCredits) return 'Adquira créditos SMS em Recursos Extras para ativar o canal.';
if (sms.value.balance <= 0) return 'Saldo zerado. Os envios de SMS estão pausados.';
return `Saldo de ${sms.value.balance} créditos disponíveis para envio.`;
});
const emailTag = computed(() => {
return { label: 'Ativo', severity: 'success', icon: 'pi pi-check-circle' };
});
const emailDetails = computed(() => {
if (email.value.templatesCount > 0) {
return `${email.value.templatesCount} template(s) personalizados.`;
}
return 'Usando templates padrão. Personalize se desejar.';
});
// ── Load ──────────────────────────────────────────────────────
async function loadUser() {
const {
data: { user }
} = await supabase.auth.getUser();
if (!user) return;
userId.value = user.id;
tenantId.value = tenantStore.activeTenantId || user.id;
}
async function loadWhatsApp() {
if (!tenantId.value) return;
let { data } = await supabase.from('notification_channels').select('credentials, connection_status').eq('tenant_id', tenantId.value).eq('channel', 'whatsapp').is('deleted_at', null).maybeSingle();
// Fallback owner_id
if (!data && userId.value && userId.value !== tenantId.value) {
const fb = await supabase.from('notification_channels').select('credentials, connection_status').eq('owner_id', userId.value).eq('channel', 'whatsapp').is('deleted_at', null).maybeSingle();
data = fb.data;
}
if (data?.credentials) {
whatsapp.value.configured = true;
whatsapp.value.credentials = data.credentials;
// Tenta verificar status real via Evolution API
whatsapp.value.checking = true;
try {
const res = await fetch(`${data.credentials.api_url}/instance/fetchInstances`, {
headers: { apikey: data.credentials.api_key }
});
if (res.ok) {
const instances = await res.json();
const inst = Array.isArray(instances) ? instances.find((i) => i.instance?.instanceName === data.credentials.instance_name) : null;
whatsapp.value.status = inst?.instance?.status || 'close';
} else {
whatsapp.value.status = 'close';
}
} catch {
whatsapp.value.status = 'close';
} finally {
whatsapp.value.checking = false;
}
}
}
async function loadSms() {
if (!tenantId.value) return;
const { data } = await supabase.from('addon_credits').select('balance, is_active').eq('tenant_id', tenantId.value).eq('addon_type', 'sms').eq('is_active', true).maybeSingle();
if (data) {
sms.value.hasCredits = true;
sms.value.balance = data.balance || 0;
sms.value.isActive = data.is_active;
}
}
async function loadEmail() {
if (!tenantId.value) return;
const { count } = await supabase.from('email_templates_tenant').select('id', { count: 'exact', head: true }).eq('tenant_id', tenantId.value);
email.value.templatesCount = count || 0;
}
function goTo(route) {
router.push(route);
}
// ── Init ──────────────────────────────────────────────────────
onMounted(async () => {
await loadUser();
await Promise.all([loadWhatsApp(), loadSms(), loadEmail()]);
loading.value = false;
});
</script>
<template>
<div class="flex flex-col gap-5">
<!-- Header -->
<Card>
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-bell text-xl" />
Canais de Notificação
</div>
</template>
<template #subtitle>Visão geral dos canais de comunicação com seus pacientes.</template>
</Card>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-12 text-surface-500"><i class="pi pi-spin pi-spinner mr-2 text-xl" /> Verificando canais...</div>
<!-- Channel Cards -->
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div v-for="ch in channels" :key="ch.key" class="border border-surface rounded-xl p-5 flex flex-col gap-4 cursor-pointer hover:shadow-md transition-shadow" @click="goTo(ch.route)">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<i :class="ch.icon" class="text-xl text-primary" />
</div>
<span class="font-semibold text-lg">{{ ch.label }}</span>
</div>
<Tag :value="ch.tag.label" :severity="ch.tag.severity" :icon="ch.tag.icon" />
</div>
<!-- Descrição -->
<p class="text-sm text-surface-500 m-0">{{ ch.description }}</p>
<!-- Status detalhe -->
<div class="text-xs text-surface-400 mt-auto">
{{ ch.details }}
</div>
<!-- Link -->
<div class="flex justify-end">
<Button label="Configurar" icon="pi pi-arrow-right" iconPos="right" size="small" text @click.stop="goTo(ch.route)" />
</div>
</div>
</div>
<!-- Resumo rápido -->
<Card>
<template #title>Como funciona</template>
<template #content>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 font-semibold">
<i class="pi pi-whatsapp text-green-500" />
WhatsApp
</div>
<p class="text-surface-500 m-0">Conecte via Evolution API e QR Code. Mensagens automáticas de lembrete, confirmação e cancelamento.</p>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 font-semibold">
<i class="pi pi-comment text-blue-500" />
SMS
</div>
<p class="text-surface-500 m-0">Funciona com créditos pré-pagos. Adquira pacotes em Recursos Extras. Ideal para pacientes sem WhatsApp.</p>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 font-semibold">
<i class="pi pi-envelope text-orange-500" />
E-mail
</div>
<p class="text-surface-500 m-0">Ativo por padrão. Personalize os templates de e-mail com o visual da sua clínica.</p>
</div>
</div>
</template>
</Card>
</div>
</template>
File diff suppressed because it is too large Load Diff
@@ -15,474 +15,554 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { useToast } from 'primevue/usetoast'
import { usePatientDiscounts } from '@/features/agenda/composables/usePatientDiscounts'
import { ref, computed, onMounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useToast } from 'primevue/usetoast';
import { usePatientDiscounts } from '@/features/agenda/composables/usePatientDiscounts';
const toast = useToast()
const tenantStore = useTenantStore()
const toast = useToast();
const tenantStore = useTenantStore();
const { discounts, loading, error: discountsError, load, save, remove } = usePatientDiscounts()
const { discounts, loading, error: discountsError, load, save, remove } = usePatientDiscounts();
const ownerId = ref(null)
const tenantId = ref(null)
const pageLoading = ref(true)
const patients = ref([])
const ownerId = ref(null);
const tenantId = ref(null);
const pageLoading = ref(true);
const patients = ref([]);
// ── Formulário ────────────────────────────────────────────────────────
const emptyForm = () => ({
patient_id: null,
discount_pct: 0,
discount_flat: 0,
reason: '',
active_from: null,
active_to: null,
})
patient_id: null,
discount_pct: 0,
discount_flat: 0,
reason: '',
active_from: null,
active_to: null
});
const newForm = ref(emptyForm())
const addingNew = ref(false)
const savingNew = ref(false)
const newForm = ref(emptyForm());
const addingNew = ref(false);
const savingNew = ref(false);
// ── Edição inline ─────────────────────────────────────────────────────
const editingId = ref(null)
const editForm = ref({})
const savingEdit = ref(false)
const editingId = ref(null);
const editForm = ref({});
const savingEdit = ref(false);
// ── Lookup de nome do paciente ────────────────────────────────────────
const patientMap = computed(() => {
const map = {}
for (const p of patients.value) map[p.id] = p.nome_completo
return map
})
const map = {};
for (const p of patients.value) map[p.id] = p.nome_completo;
return map;
});
function patientName (pid) {
return patientMap.value[pid] || pid || '—'
function patientName(pid) {
return patientMap.value[pid] || pid || '—';
}
// ── Editar ────────────────────────────────────────────────────────────
function startEdit (disc) {
editingId.value = disc.id
editForm.value = {
id: disc.id,
owner_id: ownerId.value,
tenant_id: tenantId.value,
patient_id: disc.patient_id,
discount_pct: disc.discount_pct != null ? Number(disc.discount_pct) : 0,
discount_flat: disc.discount_flat != null ? Number(disc.discount_flat) : 0,
reason: disc.reason ?? '',
active_from: disc.active_from ? new Date(disc.active_from) : null,
active_to: disc.active_to ? new Date(disc.active_to) : null,
}
function startEdit(disc) {
editingId.value = disc.id;
editForm.value = {
id: disc.id,
owner_id: ownerId.value,
tenant_id: tenantId.value,
patient_id: disc.patient_id,
discount_pct: disc.discount_pct != null ? Number(disc.discount_pct) : 0,
discount_flat: disc.discount_flat != null ? Number(disc.discount_flat) : 0,
reason: disc.reason ?? '',
active_from: disc.active_from ? new Date(disc.active_from) : null,
active_to: disc.active_to ? new Date(disc.active_to) : null
};
}
function cancelEdit () {
editingId.value = null
editForm.value = {}
function cancelEdit() {
editingId.value = null;
editForm.value = {};
}
async function saveEdit () {
if (!editForm.value.discount_pct && !editForm.value.discount_flat) {
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Informe ao menos um desconto (% ou R$).', life: 3000 })
return
}
savingEdit.value = true
try {
await save({
...editForm.value,
discount_pct: editForm.value.discount_pct ?? 0,
discount_flat: editForm.value.discount_flat ?? 0,
reason: editForm.value.reason?.trim() || null,
active_from: editForm.value.active_from ? editForm.value.active_from.toISOString().slice(0, 10) : null,
active_to: editForm.value.active_to ? editForm.value.active_to.toISOString().slice(0, 10) : null,
})
await load(ownerId.value)
cancelEdit()
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Desconto atualizado.', life: 3000 })
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: discountsError.value || 'Falha ao salvar.', life: 4000 })
} finally {
savingEdit.value = false
}
async function saveEdit() {
if (!editForm.value.discount_pct && !editForm.value.discount_flat) {
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Informe ao menos um desconto (% ou R$).', life: 3000 });
return;
}
savingEdit.value = true;
try {
await save({
...editForm.value,
discount_pct: editForm.value.discount_pct ?? 0,
discount_flat: editForm.value.discount_flat ?? 0,
reason: editForm.value.reason?.trim() || null,
active_from: editForm.value.active_from ? editForm.value.active_from.toISOString().slice(0, 10) : null,
active_to: editForm.value.active_to ? editForm.value.active_to.toISOString().slice(0, 10) : null
});
await load(ownerId.value);
cancelEdit();
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Desconto atualizado.', life: 3000 });
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: discountsError.value || 'Falha ao salvar.', life: 4000 });
} finally {
savingEdit.value = false;
}
}
// ── Novo desconto ─────────────────────────────────────────────────────
async function saveNew () {
if (!newForm.value.patient_id) {
toast.add({ severity: 'warn', summary: 'Campo obrigatório', detail: 'Selecione um paciente.', life: 3000 })
return
}
if (!newForm.value.discount_pct && !newForm.value.discount_flat) {
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Informe ao menos um desconto (% ou R$).', life: 3000 })
return
}
savingNew.value = true
try {
await save({
owner_id: ownerId.value,
tenant_id: tenantId.value,
patient_id: newForm.value.patient_id,
discount_pct: newForm.value.discount_pct ?? 0,
discount_flat: newForm.value.discount_flat ?? 0,
reason: newForm.value.reason?.trim() || null,
active_from: newForm.value.active_from ? newForm.value.active_from.toISOString().slice(0, 10) : null,
active_to: newForm.value.active_to ? newForm.value.active_to.toISOString().slice(0, 10) : null,
active: true,
})
await load(ownerId.value)
newForm.value = emptyForm()
addingNew.value = false
toast.add({ severity: 'success', summary: 'Adicionado', detail: 'Desconto criado com sucesso.', life: 3000 })
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: discountsError.value || 'Falha ao criar.', life: 4000 })
} finally {
savingNew.value = false
}
async function saveNew() {
if (!newForm.value.patient_id) {
toast.add({ severity: 'warn', summary: 'Campo obrigatório', detail: 'Selecione um paciente.', life: 3000 });
return;
}
if (!newForm.value.discount_pct && !newForm.value.discount_flat) {
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Informe ao menos um desconto (% ou R$).', life: 3000 });
return;
}
savingNew.value = true;
try {
await save({
owner_id: ownerId.value,
tenant_id: tenantId.value,
patient_id: newForm.value.patient_id,
discount_pct: newForm.value.discount_pct ?? 0,
discount_flat: newForm.value.discount_flat ?? 0,
reason: newForm.value.reason?.trim() || null,
active_from: newForm.value.active_from ? newForm.value.active_from.toISOString().slice(0, 10) : null,
active_to: newForm.value.active_to ? newForm.value.active_to.toISOString().slice(0, 10) : null,
active: true
});
await load(ownerId.value);
newForm.value = emptyForm();
addingNew.value = false;
toast.add({ severity: 'success', summary: 'Adicionado', detail: 'Desconto criado com sucesso.', life: 3000 });
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: discountsError.value || 'Falha ao criar.', life: 4000 });
} finally {
savingNew.value = false;
}
}
// ── Desativar (soft-delete) ───────────────────────────────────────────
async function confirmRemove (id) {
try {
await remove(id)
toast.add({ severity: 'success', summary: 'Desativado', detail: 'Desconto desativado.', life: 3000 })
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: discountsError.value || 'Falha ao desativar.', life: 4000 })
}
async function confirmRemove(id) {
try {
await remove(id);
toast.add({ severity: 'success', summary: 'Desativado', detail: 'Desconto desativado.', life: 3000 });
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: discountsError.value || 'Falha ao desativar.', life: 4000 });
}
}
// ── Helpers de exibição ───────────────────────────────────────────────
function fmtBRL (v) {
if (v == null || v === '' || Number(v) === 0) return null
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
function fmtBRL(v) {
if (v == null || v === '' || Number(v) === 0) return null;
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function fmtPct (v) {
if (v == null || Number(v) === 0) return null
return `${Number(v).toLocaleString('pt-BR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}%`
function fmtPct(v) {
if (v == null || Number(v) === 0) return null;
return `${Number(v).toLocaleString('pt-BR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}%`;
}
function fmtDate (v) {
if (!v) return null
const d = new Date(v)
return d.toLocaleDateString('pt-BR')
function fmtDate(v) {
if (!v) return null;
const d = new Date(v);
return d.toLocaleDateString('pt-BR');
}
// ── Mount ─────────────────────────────────────────────────────────────
onMounted(async () => {
try {
const uid = tenantStore.user?.id || (await supabase.auth.getUser()).data?.user?.id
if (!uid) return
try {
const uid = tenantStore.user?.id || (await supabase.auth.getUser()).data?.user?.id;
if (!uid) return;
ownerId.value = uid
tenantId.value = tenantStore.activeTenantId || null
ownerId.value = uid;
tenantId.value = tenantStore.activeTenantId || null;
const [, { data: pData }] = await Promise.all([
load(uid),
supabase
.from('patients')
.select('id, nome_completo')
.eq('owner_id', uid)
.eq('status', 'Ativo')
.order('nome_completo', { ascending: true }),
])
const [, { data: pData }] = await Promise.all([load(uid), supabase.from('patients').select('id, nome_completo').eq('owner_id', uid).eq('status', 'Ativo').order('nome_completo', { ascending: true })]);
patients.value = pData || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4000 })
} finally {
pageLoading.value = false
}
})
patients.value = pData || [];
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4000 });
} finally {
pageLoading.value = false;
}
});
</script>
<template>
<div class="flex flex-col gap-3">
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-percentage" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Descontos por Paciente</div>
<div class="cfg-subheader__sub">Descontos recorrentes aplicados automaticamente por paciente</div>
</div>
<div class="cfg-subheader__actions">
<Button
label="Novo desconto"
icon="pi pi-plus"
size="small"
:disabled="pageLoading || addingNew"
class="rounded-full"
@click="
addingNew = true;
cancelEdit();
"
/>
</div>
</div>
<div class="flex flex-col gap-3">
<!-- SKELETON -->
<template v-if="pageLoading || loading">
<div class="cfg-wrap">
<div class="cfg-wrap__head">
<Skeleton width="1.75rem" height="1.75rem" border-radius="6px" />
<Skeleton width="11rem" height="12px" />
</div>
<div v-for="n in 3" :key="n" class="flex items-center gap-3 px-4 py-3 border-b border-[var(--surface-border)] last:border-b-0">
<div class="flex flex-col gap-2 flex-1">
<Skeleton :width="n % 2 === 0 ? '14rem' : '10rem'" height="11px" />
<Skeleton width="8rem" height="10px" />
</div>
<Skeleton width="4rem" height="1.4rem" border-radius="999px" />
</div>
</div>
<AppLoadingPhrases action="Carregando descontos por paciente..." containerClass="py-6" />
</template>
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-percentage" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Descontos por Paciente</div>
<div class="cfg-subheader__sub">Descontos recorrentes aplicados automaticamente por paciente</div>
</div>
<div class="cfg-subheader__actions">
<Button label="Novo desconto" icon="pi pi-plus" size="small" :disabled="pageLoading || addingNew" class="rounded-full" @click="addingNew = true; cancelEdit()" />
</div>
<template v-else>
<!-- Lista + form -->
<div v-if="discounts.length || addingNew" class="cfg-wrap">
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-percentage" /></div>
<span class="cfg-wrap__title">Descontos cadastrados</span>
<span class="cfg-wrap__count">{{ discounts.length }}</span>
</div>
<div class="dsc-list">
<template v-for="disc in discounts" :key="disc.id">
<!-- Edição inline -->
<div v-if="editingId === disc.id" class="dsc-form-row dsc-form-row--editing">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<Select v-model="editForm.patient_id" inputId="edit-patient" :options="patients" optionLabel="nome_completo" optionValue="id" disabled class="w-full" />
<label for="edit-patient">Paciente</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="editForm.discount_pct" inputId="edit-pct" :min="0" :max="100" :minFractionDigits="0" :maxFractionDigits="2" suffix="%" fluid />
<label for="edit-pct">Desconto %</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="editForm.discount_flat" inputId="edit-flat" mode="currency" currency="BRL" locale="pt-BR" :min="0" fluid />
<label for="edit-flat">Desconto R$</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker v-model="editForm.active_from" inputId="edit-from" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="edit-from">Vigência: de</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker v-model="editForm.active_to" inputId="edit-to" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="edit-to">Vigência: até</label>
</FloatLabel>
</div>
<div class="col-span-12">
<FloatLabel variant="on">
<InputText v-model="editForm.reason" inputId="edit-reason" class="w-full" />
<label for="edit-reason">Motivo (opcional)</label>
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" icon="pi pi-times" size="small" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="savingEdit" class="rounded-full" @click="saveEdit" />
</div>
</div>
<!-- Leitura -->
<div v-else class="dsc-row">
<div class="dsc-row__info">
<div class="font-semibold text-sm">{{ patientName(disc.patient_id) }}</div>
<div class="flex flex-wrap gap-1.5 mt-1">
<span v-if="fmtPct(disc.discount_pct)" class="dsc-badge">{{ fmtPct(disc.discount_pct) }}</span>
<span v-if="fmtBRL(disc.discount_flat)" class="dsc-badge">{{ fmtBRL(disc.discount_flat) }}</span>
</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">
<span v-if="disc.active_from || disc.active_to"> {{ fmtDate(disc.active_from) || 'Indefinido' }} {{ fmtDate(disc.active_to) || 'Indefinido' }} </span>
<span v-else>Vigência indefinida</span>
</div>
<div v-if="disc.reason" class="text-xs text-[var(--text-color-secondary)] italic mt-0.5">{{ disc.reason }}</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Tag :value="disc.active ? 'Ativo' : 'Inativo'" :severity="disc.active ? 'success' : 'secondary'" />
<Button
icon="pi pi-pencil"
size="small"
severity="secondary"
text
v-tooltip.top="'Editar'"
@click="
startEdit(disc);
addingNew = false;
"
/>
<Button v-if="disc.active" icon="pi pi-ban" size="small" severity="danger" text v-tooltip.top="'Desativar'" @click="confirmRemove(disc.id)" />
</div>
</div>
</template>
<!-- Form novo desconto -->
<div v-if="addingNew" class="dsc-form-row dsc-form-row--new">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<Select v-model="newForm.patient_id" inputId="new-patient" :options="patients" optionLabel="nome_completo" optionValue="id" filter class="w-full" />
<label for="new-patient">Paciente *</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="newForm.discount_pct" inputId="new-pct" :min="0" :max="100" :minFractionDigits="0" :maxFractionDigits="2" suffix="%" fluid />
<label for="new-pct">Desconto %</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="newForm.discount_flat" inputId="new-flat" mode="currency" currency="BRL" locale="pt-BR" :min="0" fluid />
<label for="new-flat">Desconto R$</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker v-model="newForm.active_from" inputId="new-from" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="new-from">Vigência: de</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker v-model="newForm.active_to" inputId="new-to" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="new-to">Vigência: até</label>
</FloatLabel>
</div>
<div class="col-span-12">
<FloatLabel variant="on">
<InputText v-model="newForm.reason" inputId="new-reason" class="w-full" />
<label for="new-reason">Motivo (opcional)</label>
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-3">
<Button
label="Cancelar"
icon="pi pi-times"
size="small"
severity="secondary"
outlined
class="rounded-full"
@click="
addingNew = false;
newForm = emptyForm();
"
/>
<Button label="Adicionar" icon="pi pi-check" size="small" :loading="savingNew" class="rounded-full" @click="saveNew" />
</div>
</div>
</div>
</div>
<!-- Estado vazio -->
<div v-else class="cfg-empty">
<i class="pi pi-percentage text-3xl opacity-25" />
<div class="text-sm font-medium">Nenhum desconto cadastrado ainda.</div>
<Button label="Adicionar primeiro desconto" icon="pi pi-plus" outlined size="small" class="rounded-full" @click="addingNew = true" />
</div>
<!-- Dica -->
<Message severity="info" :closable="false">
<span class="text-sm"> Descontos ativos são aplicados automaticamente ao adicionar serviços em sessões do paciente correspondente. Você ainda pode ajustá-los manualmente no diálogo de cada evento. </span>
</Message>
<LoadedPhraseBlock />
</template>
</div>
<!-- SKELETON -->
<template v-if="pageLoading || loading">
<div class="cfg-wrap">
<div class="cfg-wrap__head">
<Skeleton width="1.75rem" height="1.75rem" border-radius="6px" />
<Skeleton width="11rem" height="12px" />
</div>
<div v-for="n in 3" :key="n" class="flex items-center gap-3 px-4 py-3 border-b border-[var(--surface-border)] last:border-b-0">
<div class="flex flex-col gap-2 flex-1">
<Skeleton :width="n % 2 === 0 ? '14rem' : '10rem'" height="11px" />
<Skeleton width="8rem" height="10px" />
</div>
<Skeleton width="4rem" height="1.4rem" border-radius="999px" />
</div>
</div>
<AppLoadingPhrases action="Carregando descontos por paciente..." containerClass="py-6" />
</template>
<template v-else>
<!-- Lista + form -->
<div v-if="discounts.length || addingNew" class="cfg-wrap">
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-percentage" /></div>
<span class="cfg-wrap__title">Descontos cadastrados</span>
<span class="cfg-wrap__count">{{ discounts.length }}</span>
</div>
<div class="dsc-list">
<template v-for="disc in discounts" :key="disc.id">
<!-- Edição inline -->
<div v-if="editingId === disc.id" class="dsc-form-row dsc-form-row--editing">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<Select v-model="editForm.patient_id" inputId="edit-patient" :options="patients" optionLabel="nome_completo" optionValue="id" disabled class="w-full" />
<label for="edit-patient">Paciente</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="editForm.discount_pct" inputId="edit-pct" :min="0" :max="100" :minFractionDigits="0" :maxFractionDigits="2" suffix="%" fluid />
<label for="edit-pct">Desconto %</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="editForm.discount_flat" inputId="edit-flat" mode="currency" currency="BRL" locale="pt-BR" :min="0" fluid />
<label for="edit-flat">Desconto R$</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker v-model="editForm.active_from" inputId="edit-from" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="edit-from">Vigência: de</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker v-model="editForm.active_to" inputId="edit-to" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="edit-to">Vigência: até</label>
</FloatLabel>
</div>
<div class="col-span-12">
<FloatLabel variant="on">
<InputText v-model="editForm.reason" inputId="edit-reason" class="w-full" />
<label for="edit-reason">Motivo (opcional)</label>
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" icon="pi pi-times" size="small" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="savingEdit" class="rounded-full" @click="saveEdit" />
</div>
</div>
<!-- Leitura -->
<div v-else class="dsc-row">
<div class="dsc-row__info">
<div class="font-semibold text-sm">{{ patientName(disc.patient_id) }}</div>
<div class="flex flex-wrap gap-1.5 mt-1">
<span v-if="fmtPct(disc.discount_pct)" class="dsc-badge">{{ fmtPct(disc.discount_pct) }}</span>
<span v-if="fmtBRL(disc.discount_flat)" class="dsc-badge">{{ fmtBRL(disc.discount_flat) }}</span>
</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">
<span v-if="disc.active_from || disc.active_to">
{{ fmtDate(disc.active_from) || 'Indefinido' }} {{ fmtDate(disc.active_to) || 'Indefinido' }}
</span>
<span v-else>Vigência indefinida</span>
</div>
<div v-if="disc.reason" class="text-xs text-[var(--text-color-secondary)] italic mt-0.5">{{ disc.reason }}</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Tag :value="disc.active ? 'Ativo' : 'Inativo'" :severity="disc.active ? 'success' : 'secondary'" />
<Button icon="pi pi-pencil" size="small" severity="secondary" text v-tooltip.top="'Editar'" @click="startEdit(disc); addingNew = false" />
<Button v-if="disc.active" icon="pi pi-ban" size="small" severity="danger" text v-tooltip.top="'Desativar'" @click="confirmRemove(disc.id)" />
</div>
</div>
</template>
<!-- Form novo desconto -->
<div v-if="addingNew" class="dsc-form-row dsc-form-row--new">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<Select v-model="newForm.patient_id" inputId="new-patient" :options="patients" optionLabel="nome_completo" optionValue="id" filter class="w-full" />
<label for="new-patient">Paciente *</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="newForm.discount_pct" inputId="new-pct" :min="0" :max="100" :minFractionDigits="0" :maxFractionDigits="2" suffix="%" fluid />
<label for="new-pct">Desconto %</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="newForm.discount_flat" inputId="new-flat" mode="currency" currency="BRL" locale="pt-BR" :min="0" fluid />
<label for="new-flat">Desconto R$</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker v-model="newForm.active_from" inputId="new-from" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="new-from">Vigência: de</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker v-model="newForm.active_to" inputId="new-to" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="new-to">Vigência: até</label>
</FloatLabel>
</div>
<div class="col-span-12">
<FloatLabel variant="on">
<InputText v-model="newForm.reason" inputId="new-reason" class="w-full" />
<label for="new-reason">Motivo (opcional)</label>
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" icon="pi pi-times" size="small" severity="secondary" outlined class="rounded-full" @click="addingNew = false; newForm = emptyForm()" />
<Button label="Adicionar" icon="pi pi-check" size="small" :loading="savingNew" class="rounded-full" @click="saveNew" />
</div>
</div>
</div>
</div>
<!-- Estado vazio -->
<div v-else class="cfg-empty">
<i class="pi pi-percentage text-3xl opacity-25" />
<div class="text-sm font-medium">Nenhum desconto cadastrado ainda.</div>
<Button label="Adicionar primeiro desconto" icon="pi pi-plus" outlined size="small" class="rounded-full" @click="addingNew = true" />
</div>
<!-- Dica -->
<Message severity="info" :closable="false">
<span class="text-sm">
Descontos ativos são aplicados automaticamente ao adicionar serviços em sessões do paciente correspondente.
Você ainda pode ajustá-los manualmente no diálogo de cada evento.
</span>
</Message>
<LoadedPhraseBlock />
</template>
</div>
</template>
<style scoped>
/* ── Subheader degradê ────────────────────────────── */
.cfg-subheader {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.875rem 1rem; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
background: linear-gradient(135deg,
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%);
position: relative; overflow: hidden;
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.875rem 1rem;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%);
position: relative;
overflow: hidden;
}
.cfg-subheader::before {
content: ''; position: absolute; top: -20px; right: -20px;
width: 80px; height: 80px; border-radius: 50%;
background: color-mix(in srgb, var(--primary-color,#6366f1) 15%, transparent);
filter: blur(20px); pointer-events: none;
content: '';
position: absolute;
top: -20px;
right: -20px;
width: 80px;
height: 80px;
border-radius: 50%;
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
filter: blur(20px);
pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 20%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.85rem;
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 6px;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
color: var(--primary-color, #6366f1);
font-size: 0.85rem;
}
.cfg-subheader__title {
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--primary-color, #6366f1);
}
.cfg-subheader__sub {
font-size: 0.75rem;
color: var(--text-color-secondary);
opacity: 0.85;
}
.cfg-subheader__actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-color,#6366f1); }
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
/* ── Card wrap ────────────────────────────────────── */
.cfg-wrap {
border: 1px solid var(--surface-border);
border-radius: 6px; background: var(--surface-card);
overflow: hidden;
border: 1px solid var(--surface-border);
border-radius: 6px;
background: var(--surface-card);
overflow: hidden;
}
.cfg-wrap__head {
display: flex; align-items: center; gap: 0.625rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
}
.cfg-wrap__icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 12%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.8rem;
display: grid;
place-items: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 6px;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 12%, transparent);
color: var(--primary-color, #6366f1);
font-size: 0.8rem;
}
.cfg-wrap__title {
font-size: 0.88rem;
font-weight: 700;
color: var(--text-color);
flex: 1;
}
.cfg-wrap__title { font-size: 0.88rem; font-weight: 700; color: var(--text-color); flex: 1; }
.cfg-wrap__count {
font-size: 0.7rem; font-weight: 700;
background: var(--primary-color,#6366f1); color: #fff;
padding: 1px 8px; border-radius: 999px; flex-shrink: 0;
font-size: 0.7rem;
font-weight: 700;
background: var(--primary-color, #6366f1);
color: #fff;
padding: 1px 8px;
border-radius: 999px;
flex-shrink: 0;
}
/* ── Lista de descontos ───────────────────────────── */
.dsc-list { display: flex; flex-direction: column; }
.dsc-list {
display: flex;
flex-direction: column;
}
/* Linha de leitura */
.dsc-row {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 0.75rem; padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
transition: background 0.1s; flex-wrap: wrap;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
transition: background 0.1s;
flex-wrap: wrap;
}
.dsc-row:last-child {
border-bottom: none;
}
.dsc-row:hover {
background: var(--surface-hover);
}
.dsc-row__info {
flex: 1;
min-width: 0;
}
.dsc-row:last-child { border-bottom: none; }
.dsc-row:hover { background: var(--surface-hover); }
.dsc-row__info { flex: 1; min-width: 0; }
/* Badge de valor */
.dsc-badge {
font-size: 0.75rem; font-weight: 600;
color: var(--primary-color,#6366f1);
background: color-mix(in srgb, var(--primary-color,#6366f1) 10%, transparent);
padding: 0.15rem 0.5rem; border-radius: 6px;
white-space: nowrap;
font-size: 0.75rem;
font-weight: 600;
color: var(--primary-color, #6366f1);
background: color-mix(in srgb, var(--primary-color, #6366f1) 10%, transparent);
padding: 0.15rem 0.5rem;
border-radius: 6px;
white-space: nowrap;
}
/* Form de adição/edição */
.dsc-form-row {
padding: 1rem;
border-bottom: 1px solid var(--surface-border);
padding: 1rem;
border-bottom: 1px solid var(--surface-border);
}
.dsc-form-row:last-child {
border-bottom: none;
}
.dsc-form-row:last-child { border-bottom: none; }
.dsc-form-row--editing {
background: color-mix(in srgb, var(--primary-color,#6366f1) 3%, var(--surface-card));
border-left: 3px solid color-mix(in srgb, var(--primary-color,#6366f1) 50%, transparent);
background: color-mix(in srgb, var(--primary-color, #6366f1) 3%, var(--surface-card));
border-left: 3px solid color-mix(in srgb, var(--primary-color, #6366f1) 50%, transparent);
}
.dsc-form-row--new {
background: var(--surface-ground);
border-top: 1px dashed var(--surface-border);
border-bottom: none;
background: var(--surface-ground);
border-top: 1px dashed var(--surface-border);
border-bottom: none;
}
/* ── Empty state ──────────────────────────────────── */
.cfg-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 0.75rem; padding: 2.5rem 1rem; text-align: center;
color: var(--text-color-secondary);
border: 1px dashed var(--surface-border);
border-radius: 6px; background: var(--surface-ground);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2.5rem 1rem;
text-align: center;
color: var(--text-color-secondary);
border: 1px dashed var(--surface-border);
border-radius: 6px;
background: var(--surface-ground);
}
</style>
</style>
File diff suppressed because it is too large Load Diff
@@ -15,351 +15,368 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { useToast } from 'primevue/usetoast'
import { useFinancialExceptions } from '@/features/agenda/composables/useFinancialExceptions'
import { ref, computed, onMounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useToast } from 'primevue/usetoast';
import { useFinancialExceptions } from '@/features/agenda/composables/useFinancialExceptions';
const toast = useToast()
const tenantStore = useTenantStore()
const toast = useToast();
const tenantStore = useTenantStore();
const { exceptions, loading, error: exceptionsError, load, save } = useFinancialExceptions()
const { exceptions, loading, error: exceptionsError, load, save } = useFinancialExceptions();
const ownerId = ref(null)
const tenantId = ref(null)
const pageLoading = ref(true)
const ownerId = ref(null);
const tenantId = ref(null);
const pageLoading = ref(true);
// ── Tipos de exceção fixos ────────────────────────────────────────────
const exceptionTypes = [
{ value: 'patient_no_show', label: 'Paciente não compareceu' },
{ value: 'patient_cancellation', label: 'Cancelamento pelo paciente' },
{ value: 'professional_cancellation', label: 'Cancelamento pelo profissional' },
]
{ value: 'patient_no_show', label: 'Paciente não compareceu' },
{ value: 'patient_cancellation', label: 'Cancelamento pelo paciente' },
{ value: 'professional_cancellation', label: 'Cancelamento pelo profissional' }
];
// ── Opções de modo de cobrança ────────────────────────────────────────
const chargeModeOptions = [
{ value: 'none', label: 'Não cobrar' },
{ value: 'full', label: 'Sessão completa' },
{ value: 'fixed_fee', label: 'Taxa fixa' },
{ value: 'percentage', label: 'Percentual da sessão' },
]
{ value: 'none', label: 'Não cobrar' },
{ value: 'full', label: 'Sessão completa' },
{ value: 'fixed_fee', label: 'Taxa fixa' },
{ value: 'percentage', label: 'Percentual da sessão' }
];
// ── Severidade do badge por charge_mode ──────────────────────────────
const chargeModeSeverity = {
none: 'secondary',
full: 'danger',
fixed_fee: 'warn',
percentage: 'info',
}
none: 'secondary',
full: 'danger',
fixed_fee: 'warn',
percentage: 'info'
};
// ── Lookup: para cada exception_type, o registro ativo (owner > clínica) ──
// Prioridade: registro próprio do owner > registro global (owner_id IS NULL)
function recordFor (type) {
const own = exceptions.value.find(e => e.exception_type === type && e.owner_id !== null)
const global = exceptions.value.find(e => e.exception_type === type && e.owner_id === null)
return own ?? global ?? null
function recordFor(type) {
const own = exceptions.value.find((e) => e.exception_type === type && e.owner_id !== null);
const global = exceptions.value.find((e) => e.exception_type === type && e.owner_id === null);
return own ?? global ?? null;
}
function isGlobalRecord (rec) {
return rec?.owner_id === null
function isGlobalRecord(rec) {
return rec?.owner_id === null;
}
// ── Texto descritivo do charge_mode ──────────────────────────────────
function chargeModeLabel (mode) {
return chargeModeOptions.find(o => o.value === mode)?.label ?? mode ?? '—'
function chargeModeLabel(mode) {
return chargeModeOptions.find((o) => o.value === mode)?.label ?? mode ?? '—';
}
function fmtBRL (v) {
if (v == null || v === '') return '—'
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
function fmtBRL(v) {
if (v == null || v === '') return '—';
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function summaryFor (rec) {
if (!rec) return 'Não configurado (padrão: não cobrar)'
switch (rec.charge_mode) {
case 'none': return 'Não cobrar'
case 'full': return 'Cobrar sessão completa'
case 'fixed_fee': return `Taxa fixa: ${fmtBRL(rec.charge_value)}`
case 'percentage': return `${rec.charge_pct ?? 0}% da sessão`
default: return '—'
}
function summaryFor(rec) {
if (!rec) return 'Não configurado (padrão: não cobrar)';
switch (rec.charge_mode) {
case 'none':
return 'Não cobrar';
case 'full':
return 'Cobrar sessão completa';
case 'fixed_fee':
return `Taxa fixa: ${fmtBRL(rec.charge_value)}`;
case 'percentage':
return `${rec.charge_pct ?? 0}% da sessão`;
default:
return '—';
}
}
// ── Edição inline ─────────────────────────────────────────────────────
const editingType = ref(null)
const editForm = ref({})
const savingEdit = ref(false)
const editingType = ref(null);
const editForm = ref({});
const savingEdit = ref(false);
function startEdit (type) {
const rec = recordFor(type)
editingType.value = type
editForm.value = {
id: rec?.id ?? null,
exception_type: type,
charge_mode: rec?.charge_mode ?? 'none',
charge_value: rec?.charge_value != null ? Number(rec.charge_value) : null,
charge_pct: rec?.charge_pct != null ? Number(rec.charge_pct) : null,
min_hours_notice: rec?.min_hours_notice != null ? Number(rec.min_hours_notice) : null,
}
function startEdit(type) {
const rec = recordFor(type);
editingType.value = type;
editForm.value = {
id: rec?.id ?? null,
exception_type: type,
charge_mode: rec?.charge_mode ?? 'none',
charge_value: rec?.charge_value != null ? Number(rec.charge_value) : null,
charge_pct: rec?.charge_pct != null ? Number(rec.charge_pct) : null,
min_hours_notice: rec?.min_hours_notice != null ? Number(rec.min_hours_notice) : null
};
}
function cancelEdit () {
editingType.value = null
editForm.value = {}
function cancelEdit() {
editingType.value = null;
editForm.value = {};
}
async function saveEdit () {
savingEdit.value = true
try {
await save({
id: editForm.value.id,
owner_id: ownerId.value,
tenant_id: tenantId.value,
exception_type: editForm.value.exception_type,
charge_mode: editForm.value.charge_mode,
charge_value: editForm.value.charge_mode === 'fixed_fee' ? (editForm.value.charge_value ?? null) : null,
charge_pct: editForm.value.charge_mode === 'percentage' ? (editForm.value.charge_pct ?? null) : null,
min_hours_notice: editForm.value.exception_type === 'patient_cancellation'
? (editForm.value.min_hours_notice ?? null)
: null,
})
await load(ownerId.value)
cancelEdit()
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Configuração atualizada.', life: 3000 })
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: exceptionsError.value || 'Falha ao salvar.', life: 4000 })
} finally {
savingEdit.value = false
}
async function saveEdit() {
savingEdit.value = true;
try {
await save({
id: editForm.value.id,
owner_id: ownerId.value,
tenant_id: tenantId.value,
exception_type: editForm.value.exception_type,
charge_mode: editForm.value.charge_mode,
charge_value: editForm.value.charge_mode === 'fixed_fee' ? (editForm.value.charge_value ?? null) : null,
charge_pct: editForm.value.charge_mode === 'percentage' ? (editForm.value.charge_pct ?? null) : null,
min_hours_notice: editForm.value.exception_type === 'patient_cancellation' ? (editForm.value.min_hours_notice ?? null) : null
});
await load(ownerId.value);
cancelEdit();
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Configuração atualizada.', life: 3000 });
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: exceptionsError.value || 'Falha ao salvar.', life: 4000 });
} finally {
savingEdit.value = false;
}
}
// ── Computed auxiliares usados no template ────────────────────────────
const showChargeValue = computed(() => editForm.value.charge_mode === 'fixed_fee')
const showChargePct = computed(() => editForm.value.charge_mode === 'percentage')
const showMinHours = computed(() => editForm.value.exception_type === 'patient_cancellation')
const showChargeValue = computed(() => editForm.value.charge_mode === 'fixed_fee');
const showChargePct = computed(() => editForm.value.charge_mode === 'percentage');
const showMinHours = computed(() => editForm.value.exception_type === 'patient_cancellation');
// ── Mount ─────────────────────────────────────────────────────────────
onMounted(async () => {
try {
const uid = tenantStore.user?.id || (await supabase.auth.getUser()).data?.user?.id
if (!uid) return
try {
const uid = tenantStore.user?.id || (await supabase.auth.getUser()).data?.user?.id;
if (!uid) return;
ownerId.value = uid
tenantId.value = tenantStore.activeTenantId || null
ownerId.value = uid;
tenantId.value = tenantStore.activeTenantId || null;
await load(uid)
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4000 })
} finally {
pageLoading.value = false
}
})
await load(uid);
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4000 });
} finally {
pageLoading.value = false;
}
});
</script>
<template>
<div class="flex flex-col gap-3">
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-exclamation-triangle" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Exceções Financeiras</div>
<div class="cfg-subheader__sub">Defina o que cobrar em cancelamentos, faltas e situações excepcionais</div>
</div>
</div>
<div class="flex flex-col gap-3">
<!-- SKELETON -->
<template v-if="pageLoading || loading">
<div v-for="n in 3" :key="n" class="cfg-wrap">
<div class="cfg-wrap__head">
<Skeleton width="1.75rem" height="1.75rem" border-radius="6px" />
<Skeleton :width="n === 1 ? '13rem' : n === 2 ? '11rem' : '15rem'" height="12px" />
<Skeleton width="4rem" height="1.4rem" border-radius="999px" class="ml-auto" />
</div>
<div class="px-4 py-3">
<Skeleton width="16rem" height="10px" />
</div>
</div>
<AppLoadingPhrases action="Carregando exceções financeiras..." containerClass="py-6" />
</template>
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-exclamation-triangle" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Exceções Financeiras</div>
<div class="cfg-subheader__sub">Defina o que cobrar em cancelamentos, faltas e situações excepcionais</div>
</div>
<template v-else>
<!-- Um card por tipo de exceção -->
<div v-for="et in exceptionTypes" :key="et.value" class="cfg-wrap">
<!-- Cabeçalho do card -->
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-exclamation-triangle" /></div>
<span class="cfg-wrap__title">{{ et.label }}</span>
<div class="ml-auto flex items-center gap-2 shrink-0">
<template v-if="recordFor(et.value)">
<Tag :value="chargeModeLabel(recordFor(et.value)?.charge_mode)" :severity="chargeModeSeverity[recordFor(et.value)?.charge_mode] ?? 'secondary'" />
<Tag v-if="isGlobalRecord(recordFor(et.value))" value="Regra da clínica" severity="secondary" />
</template>
<Button v-if="editingType !== et.value && !isGlobalRecord(recordFor(et.value))" label="Configurar" icon="pi pi-cog" size="small" severity="secondary" outlined class="rounded-full" @click="startEdit(et.value)" />
</div>
</div>
<!-- Modo leitura -->
<div v-if="editingType !== et.value" class="exc-read">
<template v-if="recordFor(et.value)">
<div class="text-sm text-[var(--text-color-secondary)]">
{{ summaryFor(recordFor(et.value)) }}
<span v-if="et.value === 'patient_cancellation' && recordFor(et.value)?.min_hours_notice"> cobrar apenas se cancelado com menos de {{ recordFor(et.value).min_hours_notice }}h de antecedência </span>
</div>
</template>
<div v-else class="text-sm text-[var(--text-color-secondary)] italic">Não configurado comportamento padrão: não cobrar.</div>
</div>
<!-- Modo edição -->
<div v-else class="exc-edit">
<!-- Modo de cobrança -->
<div>
<label class="exc-label">Modo de cobrança</label>
<SelectButton v-model="editForm.charge_mode" :options="chargeModeOptions" optionLabel="label" optionValue="value" class="flex-wrap mt-1" />
</div>
<div class="grid grid-cols-12 gap-3">
<!-- Taxa fixa -->
<div v-if="showChargeValue" class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputNumber v-model="editForm.charge_value" inputId="edit-charge-value" mode="currency" currency="BRL" locale="pt-BR" :min="0" fluid />
<label for="edit-charge-value">Taxa fixa (R$)</label>
</FloatLabel>
</div>
<!-- Percentual -->
<div v-if="showChargePct" class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputNumber v-model="editForm.charge_pct" inputId="edit-charge-pct" :min="0" :max="100" :minFractionDigits="0" :maxFractionDigits="2" suffix="%" fluid />
<label for="edit-charge-pct">Percentual da sessão (%)</label>
</FloatLabel>
</div>
<!-- Antecedência mínima -->
<div v-if="showMinHours" class="col-span-12 sm:col-span-6">
<FloatLabel variant="on">
<InputNumber v-model="editForm.min_hours_notice" inputId="edit-min-hours" :min="0" :max="720" suffix=" h" fluid showButtons />
<label for="edit-min-hours">Cobrar se cancelado com menos de X horas (vazio = sempre)</label>
</FloatLabel>
<small class="text-[var(--text-color-secondary)] opacity-70 mt-1 block text-xs"> Deixe em branco para cobrar independentemente da antecedência. </small>
</div>
</div>
<!-- Botões na linha separada -->
<div class="flex gap-2 justify-end mt-1">
<Button label="Cancelar" icon="pi pi-times" size="small" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="savingEdit" class="rounded-full" @click="saveEdit" />
</div>
</div>
</div>
<!-- Dica -->
<Message severity="info" :closable="false">
<span class="text-sm"> Estas configurações definem o comportamento padrão de cobrança. Você pode ajustá-las individualmente em cada evento na agenda. </span>
</Message>
<LoadedPhraseBlock />
</template>
</div>
<!-- SKELETON -->
<template v-if="pageLoading || loading">
<div v-for="n in 3" :key="n" class="cfg-wrap">
<div class="cfg-wrap__head">
<Skeleton width="1.75rem" height="1.75rem" border-radius="6px" />
<Skeleton :width="n === 1 ? '13rem' : n === 2 ? '11rem' : '15rem'" height="12px" />
<Skeleton width="4rem" height="1.4rem" border-radius="999px" class="ml-auto" />
</div>
<div class="px-4 py-3">
<Skeleton width="16rem" height="10px" />
</div>
</div>
<AppLoadingPhrases action="Carregando exceções financeiras..." containerClass="py-6" />
</template>
<template v-else>
<!-- Um card por tipo de exceção -->
<div v-for="et in exceptionTypes" :key="et.value" class="cfg-wrap">
<!-- Cabeçalho do card -->
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-exclamation-triangle" /></div>
<span class="cfg-wrap__title">{{ et.label }}</span>
<div class="ml-auto flex items-center gap-2 shrink-0">
<template v-if="recordFor(et.value)">
<Tag
:value="chargeModeLabel(recordFor(et.value)?.charge_mode)"
:severity="chargeModeSeverity[recordFor(et.value)?.charge_mode] ?? 'secondary'"
/>
<Tag v-if="isGlobalRecord(recordFor(et.value))" value="Regra da clínica" severity="secondary" />
</template>
<Button
v-if="editingType !== et.value && !isGlobalRecord(recordFor(et.value))"
label="Configurar"
icon="pi pi-cog"
size="small"
severity="secondary"
outlined
class="rounded-full"
@click="startEdit(et.value)"
/>
</div>
</div>
<!-- Modo leitura -->
<div v-if="editingType !== et.value" class="exc-read">
<template v-if="recordFor(et.value)">
<div class="text-sm text-[var(--text-color-secondary)]">
{{ summaryFor(recordFor(et.value)) }}
<span v-if="et.value === 'patient_cancellation' && recordFor(et.value)?.min_hours_notice">
cobrar apenas se cancelado com menos de {{ recordFor(et.value).min_hours_notice }}h de antecedência
</span>
</div>
</template>
<div v-else class="text-sm text-[var(--text-color-secondary)] italic">
Não configurado comportamento padrão: não cobrar.
</div>
</div>
<!-- Modo edição -->
<div v-else class="exc-edit">
<!-- Modo de cobrança -->
<div>
<label class="exc-label">Modo de cobrança</label>
<SelectButton
v-model="editForm.charge_mode"
:options="chargeModeOptions"
optionLabel="label"
optionValue="value"
class="flex-wrap mt-1"
/>
</div>
<div class="grid grid-cols-12 gap-3">
<!-- Taxa fixa -->
<div v-if="showChargeValue" class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputNumber v-model="editForm.charge_value" inputId="edit-charge-value" mode="currency" currency="BRL" locale="pt-BR" :min="0" fluid />
<label for="edit-charge-value">Taxa fixa (R$)</label>
</FloatLabel>
</div>
<!-- Percentual -->
<div v-if="showChargePct" class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputNumber v-model="editForm.charge_pct" inputId="edit-charge-pct" :min="0" :max="100" :minFractionDigits="0" :maxFractionDigits="2" suffix="%" fluid />
<label for="edit-charge-pct">Percentual da sessão (%)</label>
</FloatLabel>
</div>
<!-- Antecedência mínima -->
<div v-if="showMinHours" class="col-span-12 sm:col-span-6">
<FloatLabel variant="on">
<InputNumber v-model="editForm.min_hours_notice" inputId="edit-min-hours" :min="0" :max="720" suffix=" h" fluid showButtons />
<label for="edit-min-hours">Cobrar se cancelado com menos de X horas (vazio = sempre)</label>
</FloatLabel>
<small class="text-[var(--text-color-secondary)] opacity-70 mt-1 block text-xs">
Deixe em branco para cobrar independentemente da antecedência.
</small>
</div>
</div>
<!-- Botões na linha separada -->
<div class="flex gap-2 justify-end mt-1">
<Button label="Cancelar" icon="pi pi-times" size="small" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="savingEdit" class="rounded-full" @click="saveEdit" />
</div>
</div>
</div>
<!-- Dica -->
<Message severity="info" :closable="false">
<span class="text-sm">
Estas configurações definem o comportamento padrão de cobrança. Você pode
ajustá-las individualmente em cada evento na agenda.
</span>
</Message>
<LoadedPhraseBlock />
</template>
</div>
</template>
<style scoped>
/* ── Subheader degradê ────────────────────────────── */
.cfg-subheader {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.875rem 1rem; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
background: linear-gradient(135deg,
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%);
position: relative; overflow: hidden;
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.875rem 1rem;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%);
position: relative;
overflow: hidden;
}
.cfg-subheader::before {
content: ''; position: absolute; top: -20px; right: -20px;
width: 80px; height: 80px; border-radius: 50%;
background: color-mix(in srgb, var(--primary-color,#6366f1) 15%, transparent);
filter: blur(20px); pointer-events: none;
content: '';
position: absolute;
top: -20px;
right: -20px;
width: 80px;
height: 80px;
border-radius: 50%;
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
filter: blur(20px);
pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 20%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.85rem;
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 6px;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
color: var(--primary-color, #6366f1);
font-size: 0.85rem;
}
.cfg-subheader__title {
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--primary-color, #6366f1);
}
.cfg-subheader__sub {
font-size: 0.75rem;
color: var(--text-color-secondary);
opacity: 0.85;
}
.cfg-subheader__actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-color,#6366f1); }
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
/* ── Card wrap ────────────────────────────────────── */
.cfg-wrap {
border: 1px solid var(--surface-border);
border-radius: 6px; background: var(--surface-card);
overflow: hidden;
border: 1px solid var(--surface-border);
border-radius: 6px;
background: var(--surface-card);
overflow: hidden;
}
.cfg-wrap__head {
display: flex; align-items: center; gap: 0.625rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground); flex-wrap: wrap;
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
flex-wrap: wrap;
}
.cfg-wrap__icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 12%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.8rem;
display: grid;
place-items: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 6px;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 12%, transparent);
color: var(--primary-color, #6366f1);
font-size: 0.8rem;
}
.cfg-wrap__title {
font-size: 0.88rem;
font-weight: 700;
color: var(--text-color);
}
.cfg-wrap__title { font-size: 0.88rem; font-weight: 700; color: var(--text-color); }
/* ── Leitura ──────────────────────────────────────── */
.exc-read {
padding: 0.75rem 1rem;
padding: 0.75rem 1rem;
}
/* ── Edição ───────────────────────────────────────── */
.exc-edit {
padding: 1rem;
display: flex; flex-direction: column; gap: 1rem;
background: color-mix(in srgb, var(--primary-color,#6366f1) 3%, var(--surface-card));
border-top: 2px solid color-mix(in srgb, var(--primary-color,#6366f1) 40%, transparent);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
background: color-mix(in srgb, var(--primary-color, #6366f1) 3%, var(--surface-card));
border-top: 2px solid color-mix(in srgb, var(--primary-color, #6366f1) 40%, transparent);
}
/* ── Label ────────────────────────────────────────── */
.exc-label {
display: block; font-size: 0.75rem; font-weight: 600;
color: var(--text-color-secondary); margin-bottom: 0.375rem;
display: block;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-color-secondary);
margin-bottom: 0.375rem;
}
</style>
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -15,402 +15,467 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { useToast } from 'primevue/usetoast'
import { useServices } from '@/features/agenda/composables/useServices'
import { ref, computed, onMounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useToast } from 'primevue/usetoast';
import { useServices } from '@/features/agenda/composables/useServices';
const toast = useToast()
const tenantStore = useTenantStore()
const toast = useToast();
const tenantStore = useTenantStore();
const { services, loading, error: servicesError, load, save, toggle, remove } = useServices()
const { services, loading, error: servicesError, load, save, toggle, remove } = useServices();
const ownerId = ref(null)
const tenantId = ref(null)
const slotMode = ref('fixed')
const pageLoading = ref(true)
const ownerId = ref(null);
const tenantId = ref(null);
const slotMode = ref('fixed');
const pageLoading = ref(true);
const isDynamic = computed(() => slotMode.value === 'dynamic')
const isDynamic = computed(() => slotMode.value === 'dynamic');
// ── Formulário novo serviço ──────────────────────────────────────────
const emptyForm = () => ({ name: '', description: '', price: null, duration_min: null })
const emptyForm = () => ({ name: '', description: '', price: null, duration_min: null });
const newForm = ref(emptyForm())
const addingNew = ref(false)
const savingNew = ref(false)
const newForm = ref(emptyForm());
const addingNew = ref(false);
const savingNew = ref(false);
// ── Edição inline ────────────────────────────────────────────────────
const editingId = ref(null)
const editForm = ref({})
const savingEdit = ref(false)
const editingId = ref(null);
const editForm = ref({});
const savingEdit = ref(false);
function startEdit (svc) {
editingId.value = svc.id
editForm.value = {
id: svc.id,
owner_id: ownerId.value,
tenant_id: tenantId.value,
name: svc.name,
description: svc.description ?? '',
price: svc.price != null ? Number(svc.price) : null,
duration_min: svc.duration_min ?? null,
}
function startEdit(svc) {
editingId.value = svc.id;
editForm.value = {
id: svc.id,
owner_id: ownerId.value,
tenant_id: tenantId.value,
name: svc.name,
description: svc.description ?? '',
price: svc.price != null ? Number(svc.price) : null,
duration_min: svc.duration_min ?? null
};
}
function cancelEdit () {
editingId.value = null
editForm.value = {}
function cancelEdit() {
editingId.value = null;
editForm.value = {};
}
async function saveEdit () {
if (!editForm.value.name?.trim() || editForm.value.price == null) {
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Nome e preço são obrigatórios.', life: 3000 })
return
}
savingEdit.value = true
try {
await save({
...editForm.value,
name: editForm.value.name.trim(),
description: editForm.value.description?.trim() || null,
duration_min: isDynamic.value ? (editForm.value.duration_min ?? null) : null,
})
await load(ownerId.value)
cancelEdit()
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Serviço atualizado.', life: 3000 })
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: servicesError.value || 'Falha ao salvar.', life: 4000 })
} finally {
savingEdit.value = false
}
async function saveEdit() {
if (!editForm.value.name?.trim() || editForm.value.price == null) {
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Nome e preço são obrigatórios.', life: 3000 });
return;
}
savingEdit.value = true;
try {
await save({
...editForm.value,
name: editForm.value.name.trim(),
description: editForm.value.description?.trim() || null,
duration_min: isDynamic.value ? (editForm.value.duration_min ?? null) : null
});
await load(ownerId.value);
cancelEdit();
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Serviço atualizado.', life: 3000 });
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: servicesError.value || 'Falha ao salvar.', life: 4000 });
} finally {
savingEdit.value = false;
}
}
async function saveNew () {
if (!newForm.value.name?.trim() || newForm.value.price == null) {
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Nome e preço são obrigatórios.', life: 3000 })
return
}
savingNew.value = true
try {
await save({
owner_id: ownerId.value,
tenant_id: tenantId.value,
name: newForm.value.name.trim(),
description: newForm.value.description?.trim() || null,
price: newForm.value.price,
duration_min: isDynamic.value ? (newForm.value.duration_min ?? null) : null,
})
await load(ownerId.value)
newForm.value = emptyForm()
addingNew.value = false
toast.add({ severity: 'success', summary: 'Adicionado', detail: 'Serviço criado com sucesso.', life: 3000 })
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: servicesError.value || 'Falha ao criar.', life: 4000 })
} finally {
savingNew.value = false
}
async function saveNew() {
if (!newForm.value.name?.trim() || newForm.value.price == null) {
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Nome e preço são obrigatórios.', life: 3000 });
return;
}
savingNew.value = true;
try {
await save({
owner_id: ownerId.value,
tenant_id: tenantId.value,
name: newForm.value.name.trim(),
description: newForm.value.description?.trim() || null,
price: newForm.value.price,
duration_min: isDynamic.value ? (newForm.value.duration_min ?? null) : null
});
await load(ownerId.value);
newForm.value = emptyForm();
addingNew.value = false;
toast.add({ severity: 'success', summary: 'Adicionado', detail: 'Serviço criado com sucesso.', life: 3000 });
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: servicesError.value || 'Falha ao criar.', life: 4000 });
} finally {
savingNew.value = false;
}
}
async function toggleService (svc) {
try {
await toggle(svc.id, !svc.active)
toast.add({ severity: 'success', summary: svc.active ? 'Desativado' : 'Ativado', detail: `Serviço ${svc.active ? 'desativado' : 'ativado'}.`, life: 3000 })
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: servicesError.value || 'Falha ao atualizar.', life: 4000 })
}
async function toggleService(svc) {
try {
await toggle(svc.id, !svc.active);
toast.add({ severity: 'success', summary: svc.active ? 'Desativado' : 'Ativado', detail: `Serviço ${svc.active ? 'desativado' : 'ativado'}.`, life: 3000 });
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: servicesError.value || 'Falha ao atualizar.', life: 4000 });
}
}
async function confirmRemove (id) {
try {
await remove(id)
toast.add({ severity: 'success', summary: 'Removido', detail: 'Serviço removido permanentemente.', life: 3000 })
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: servicesError.value || 'Falha ao remover.', life: 4000 })
}
async function confirmRemove(id) {
try {
await remove(id);
toast.add({ severity: 'success', summary: 'Removido', detail: 'Serviço removido permanentemente.', life: 3000 });
} catch {
toast.add({ severity: 'error', summary: 'Erro', detail: servicesError.value || 'Falha ao remover.', life: 4000 });
}
}
function fmtBRL (v) {
if (v == null || v === '') return '—'
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
function fmtBRL(v) {
if (v == null || v === '') return '—';
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
onMounted(async () => {
try {
const uid = tenantStore.user?.id || (await supabase.auth.getUser()).data?.user?.id
if (!uid) return
try {
const uid = tenantStore.user?.id || (await supabase.auth.getUser()).data?.user?.id;
if (!uid) return;
ownerId.value = uid
tenantId.value = tenantStore.activeTenantId || null
ownerId.value = uid;
tenantId.value = tenantStore.activeTenantId || null;
const { data: cfg } = await supabase
.from('agenda_configuracoes')
.select('slot_mode')
.eq('owner_id', uid)
.maybeSingle()
const { data: cfg } = await supabase.from('agenda_configuracoes').select('slot_mode').eq('owner_id', uid).maybeSingle();
slotMode.value = cfg?.slot_mode ?? 'fixed'
slotMode.value = cfg?.slot_mode ?? 'fixed';
await load(uid)
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4000 })
} finally {
pageLoading.value = false
}
})
await load(uid);
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4000 });
} finally {
pageLoading.value = false;
}
});
</script>
<template>
<div class="flex flex-col gap-3">
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-tag" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Precificação</div>
<div class="cfg-subheader__sub">Valor padrão da sessão e preços por tipo de compromisso</div>
</div>
<div class="cfg-subheader__actions">
<Button label="Novo serviço" icon="pi pi-plus" size="small" :disabled="pageLoading || addingNew" class="rounded-full" @click="addingNew = true; cancelEdit()" />
</div>
</div>
<!-- SKELETON -->
<template v-if="pageLoading || loading">
<div class="cfg-wrap">
<div class="cfg-wrap__head">
<Skeleton width="1.75rem" height="1.75rem" border-radius="6px" />
<Skeleton width="9rem" height="12px" />
<div class="flex flex-col gap-3">
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-tag" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Precificação</div>
<div class="cfg-subheader__sub">Valor padrão da sessão e preços por tipo de compromisso</div>
</div>
<div class="cfg-subheader__actions">
<Button
label="Novo serviço"
icon="pi pi-plus"
size="small"
:disabled="pageLoading || addingNew"
class="rounded-full"
@click="
addingNew = true;
cancelEdit();
"
/>
</div>
</div>
<div v-for="n in 3" :key="n" class="flex items-center gap-3 px-4 py-3 border-b border-[var(--surface-border)] last:border-b-0">
<Skeleton width="1.75rem" height="1.75rem" border-radius="6px" />
<div class="flex flex-col gap-2 flex-1">
<Skeleton :width="n === 1 ? '10rem' : n === 2 ? '8rem' : '12rem'" height="11px" />
<Skeleton width="6rem" height="10px" />
</div>
<Skeleton width="3.5rem" height="1.4rem" border-radius="999px" />
</div>
</div>
<AppLoadingPhrases action="Carregando serviços e precificação..." containerClass="py-6" />
</template>
<template v-else>
<Message v-if="isDynamic" severity="info" :closable="false">
<span class="text-sm">Modo <b>dinâmico</b> ativo a duração da sessão é definida pelo serviço selecionado.</span>
</Message>
<!-- Form novo serviço -->
<div v-if="addingNew" class="cfg-wrap">
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-plus" /></div>
<span class="cfg-wrap__title">Novo serviço</span>
</div>
<div class="svc-form">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputText v-model="newForm.name" inputId="new-name" class="w-full" />
<label for="new-name">Nome *</label>
</FloatLabel>
<!-- SKELETON -->
<template v-if="pageLoading || loading">
<div class="cfg-wrap">
<div class="cfg-wrap__head">
<Skeleton width="1.75rem" height="1.75rem" border-radius="6px" />
<Skeleton width="9rem" height="12px" />
</div>
<div v-for="n in 3" :key="n" class="flex items-center gap-3 px-4 py-3 border-b border-[var(--surface-border)] last:border-b-0">
<Skeleton width="1.75rem" height="1.75rem" border-radius="6px" />
<div class="flex flex-col gap-2 flex-1">
<Skeleton :width="n === 1 ? '10rem' : n === 2 ? '8rem' : '12rem'" height="11px" />
<Skeleton width="6rem" height="10px" />
</div>
<Skeleton width="3.5rem" height="1.4rem" border-radius="999px" />
</div>
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputNumber v-model="newForm.price" inputId="new-price" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" fluid />
<label for="new-price">Preço (R$) *</label>
</FloatLabel>
</div>
<div v-if="isDynamic" class="col-span-12 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="newForm.duration_min" inputId="new-duration" :min="1" :max="480" fluid />
<label for="new-duration">Duração (min)</label>
</FloatLabel>
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputText v-model="newForm.description" inputId="new-desc" class="w-full" />
<label for="new-desc">Descrição (opcional)</label>
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="addingNew = false; newForm = emptyForm()" />
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="savingNew" @click="saveNew" />
</div>
</div>
</div>
<!-- Lista vazia -->
<div v-if="!services.length && !addingNew" class="cfg-empty">
<i class="pi pi-tag text-3xl opacity-25" />
<div class="text-sm font-medium">Nenhum serviço cadastrado</div>
<div class="text-xs opacity-70">Clique em "Novo serviço" para começar.</div>
</div>
<!-- Lista de serviços -->
<div v-for="svc in services" :key="svc.id" class="cfg-wrap" :class="{ 'opacity-60': !svc.active }">
<!-- Modo leitura: head clicável -->
<template v-if="editingId !== svc.id">
<div class="svc-row">
<div class="svc-row__icon">
<i class="pi pi-tag" />
</div>
<div class="svc-row__info">
<div class="font-semibold text-sm">{{ svc.name }}</div>
<div class="flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5 text-xs text-[var(--text-color-secondary)]">
<span class="font-semibold text-[var(--primary-color)]">{{ fmtBRL(svc.price) }}</span>
<span v-if="svc.duration_min">{{ svc.duration_min }} min</span>
<span v-if="svc.description" class="italic">{{ svc.description }}</span>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<Tag :value="svc.active ? 'Ativo' : 'Inativo'" :severity="svc.active ? 'success' : 'secondary'" />
<Button
:icon="svc.active ? 'pi pi-eye-slash' : 'pi pi-eye'"
:severity="svc.active ? 'secondary' : 'success'"
outlined size="small"
v-tooltip.top="svc.active ? 'Desativar' : 'Ativar'"
@click="toggleService(svc)"
/>
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="startEdit(svc)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Remover'" @click="confirmRemove(svc.id)" />
</div>
</div>
<AppLoadingPhrases action="Carregando serviços e precificação..." containerClass="py-6" />
</template>
<!-- Modo edição -->
<template v-else>
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-pencil" /></div>
<span class="cfg-wrap__title">Editar {{ svc.name }}</span>
</div>
<div class="svc-form svc-form--editing">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputText v-model="editForm.name" :inputId="`edit-name-${svc.id}`" class="w-full" />
<label :for="`edit-name-${svc.id}`">Nome *</label>
</FloatLabel>
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputNumber v-model="editForm.price" :inputId="`edit-price-${svc.id}`" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" fluid />
<label :for="`edit-price-${svc.id}`">Preço (R$) *</label>
</FloatLabel>
</div>
<div v-if="isDynamic" class="col-span-12 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="editForm.duration_min" :inputId="`edit-dur-${svc.id}`" :min="1" :max="480" fluid />
<label :for="`edit-dur-${svc.id}`">Duração (min)</label>
</FloatLabel>
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputText v-model="editForm.description" :inputId="`edit-desc-${svc.id}`" class="w-full" />
<label :for="`edit-desc-${svc.id}`">Descrição (opcional)</label>
</FloatLabel>
</div>
<Message v-if="isDynamic" severity="info" :closable="false">
<span class="text-sm">Modo <b>dinâmico</b> ativo a duração da sessão é definida pelo serviço selecionado.</span>
</Message>
<!-- Form novo serviço -->
<div v-if="addingNew" class="cfg-wrap">
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-plus" /></div>
<span class="cfg-wrap__title">Novo serviço</span>
</div>
<div class="svc-form">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputText v-model="newForm.name" inputId="new-name" class="w-full" />
<label for="new-name">Nome *</label>
</FloatLabel>
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputNumber v-model="newForm.price" inputId="new-price" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" fluid />
<label for="new-price">Preço (R$) *</label>
</FloatLabel>
</div>
<div v-if="isDynamic" class="col-span-12 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="newForm.duration_min" inputId="new-duration" :min="1" :max="480" fluid />
<label for="new-duration">Duração (min)</label>
</FloatLabel>
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputText v-model="newForm.description" inputId="new-desc" class="w-full" />
<label for="new-desc">Descrição (opcional)</label>
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-3">
<Button
label="Cancelar"
severity="secondary"
outlined
class="rounded-full"
@click="
addingNew = false;
newForm = emptyForm();
"
/>
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="savingNew" @click="saveNew" />
</div>
</div>
</div>
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="savingEdit" @click="saveEdit" />
<!-- Lista vazia -->
<div v-if="!services.length && !addingNew" class="cfg-empty">
<i class="pi pi-tag text-3xl opacity-25" />
<div class="text-sm font-medium">Nenhum serviço cadastrado</div>
<div class="text-xs opacity-70">Clique em "Novo serviço" para começar.</div>
</div>
</div>
<!-- Lista de serviços -->
<div v-for="svc in services" :key="svc.id" class="cfg-wrap" :class="{ 'opacity-60': !svc.active }">
<!-- Modo leitura: head clicável -->
<template v-if="editingId !== svc.id">
<div class="svc-row">
<div class="svc-row__icon">
<i class="pi pi-tag" />
</div>
<div class="svc-row__info">
<div class="font-semibold text-sm">{{ svc.name }}</div>
<div class="flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5 text-xs text-[var(--text-color-secondary)]">
<span class="font-semibold text-[var(--primary-color)]">{{ fmtBRL(svc.price) }}</span>
<span v-if="svc.duration_min">{{ svc.duration_min }} min</span>
<span v-if="svc.description" class="italic">{{ svc.description }}</span>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<Tag :value="svc.active ? 'Ativo' : 'Inativo'" :severity="svc.active ? 'success' : 'secondary'" />
<Button :icon="svc.active ? 'pi pi-eye-slash' : 'pi pi-eye'" :severity="svc.active ? 'secondary' : 'success'" outlined size="small" v-tooltip.top="svc.active ? 'Desativar' : 'Ativar'" @click="toggleService(svc)" />
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="startEdit(svc)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Remover'" @click="confirmRemove(svc.id)" />
</div>
</div>
</template>
<!-- Modo edição -->
<template v-else>
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-pencil" /></div>
<span class="cfg-wrap__title">Editar {{ svc.name }}</span>
</div>
<div class="svc-form svc-form--editing">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputText v-model="editForm.name" :inputId="`edit-name-${svc.id}`" class="w-full" />
<label :for="`edit-name-${svc.id}`">Nome *</label>
</FloatLabel>
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputNumber v-model="editForm.price" :inputId="`edit-price-${svc.id}`" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" fluid />
<label :for="`edit-price-${svc.id}`">Preço (R$) *</label>
</FloatLabel>
</div>
<div v-if="isDynamic" class="col-span-12 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="editForm.duration_min" :inputId="`edit-dur-${svc.id}`" :min="1" :max="480" fluid />
<label :for="`edit-dur-${svc.id}`">Duração (min)</label>
</FloatLabel>
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputText v-model="editForm.description" :inputId="`edit-desc-${svc.id}`" class="w-full" />
<label :for="`edit-desc-${svc.id}`">Descrição (opcional)</label>
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="savingEdit" @click="saveEdit" />
</div>
</div>
</template>
</div>
<Message severity="info" :closable="false">
<span class="text-sm">Os serviços cadastrados aqui aparecem para seleção ao criar ou editar um evento na agenda.</span>
</Message>
<LoadedPhraseBlock />
</template>
</div>
<Message severity="info" :closable="false">
<span class="text-sm">Os serviços cadastrados aqui aparecem para seleção ao criar ou editar um evento na agenda.</span>
</Message>
<LoadedPhraseBlock />
</template>
</div>
</div>
</template>
<style scoped>
/* ── Subheader degradê ────────────────────────────── */
.cfg-subheader {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.875rem 1rem; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
background: linear-gradient(135deg,
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%);
position: relative; overflow: hidden;
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.875rem 1rem;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%);
position: relative;
overflow: hidden;
}
.cfg-subheader::before {
content: ''; position: absolute; top: -20px; right: -20px;
width: 80px; height: 80px; border-radius: 50%;
background: color-mix(in srgb, var(--primary-color,#6366f1) 15%, transparent);
filter: blur(20px); pointer-events: none;
content: '';
position: absolute;
top: -20px;
right: -20px;
width: 80px;
height: 80px;
border-radius: 50%;
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
filter: blur(20px);
pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 20%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.85rem;
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 6px;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
color: var(--primary-color, #6366f1);
font-size: 0.85rem;
}
.cfg-subheader__title {
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--primary-color, #6366f1);
}
.cfg-subheader__sub {
font-size: 0.75rem;
color: var(--text-color-secondary);
opacity: 0.85;
}
.cfg-subheader__actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
flex-shrink: 0;
position: relative;
z-index: 1;
}
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-color,#6366f1); }
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
/* ── Card wrap ────────────────────────────────────── */
.cfg-wrap {
border: 1px solid var(--surface-border);
border-radius: 6px; background: var(--surface-card);
overflow: hidden;
border: 1px solid var(--surface-border);
border-radius: 6px;
background: var(--surface-card);
overflow: hidden;
}
.cfg-wrap__head {
display: flex; align-items: center; gap: 0.625rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
}
.cfg-wrap__icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 12%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.8rem;
display: grid;
place-items: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 6px;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 12%, transparent);
color: var(--primary-color, #6366f1);
font-size: 0.8rem;
}
.cfg-wrap__title {
font-size: 0.88rem;
font-weight: 700;
color: var(--text-color);
}
.cfg-wrap__title { font-size: 0.88rem; font-weight: 700; color: var(--text-color); }
/* ── Linha de leitura do serviço ──────────────────── */
.svc-row {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.75rem 1rem; flex-wrap: wrap;
transition: background 0.1s;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
flex-wrap: wrap;
transition: background 0.1s;
}
.svc-row:hover {
background: var(--surface-hover);
}
.svc-row:hover { background: var(--surface-hover); }
.svc-row__icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 10%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.78rem;
display: grid;
place-items: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 6px;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 10%, transparent);
color: var(--primary-color, #6366f1);
font-size: 0.78rem;
}
.svc-row__info {
flex: 1;
min-width: 0;
}
.svc-row__info { flex: 1; min-width: 0; }
/* ── Form (novo + edição) ─────────────────────────── */
.svc-form {
padding: 1rem;
display: flex; flex-direction: column; gap: 0.75rem;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.svc-form--editing {
background: color-mix(in srgb, var(--primary-color,#6366f1) 3%, var(--surface-card));
border-top: 2px solid color-mix(in srgb, var(--primary-color,#6366f1) 40%, transparent);
background: color-mix(in srgb, var(--primary-color, #6366f1) 3%, var(--surface-card));
border-top: 2px solid color-mix(in srgb, var(--primary-color, #6366f1) 40%, transparent);
}
/* ── Empty state ──────────────────────────────────── */
.cfg-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 0.75rem; padding: 2.5rem 1rem; text-align: center;
color: var(--text-color-secondary);
border: 1px dashed var(--surface-border);
border-radius: 6px; background: var(--surface-ground);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2.5rem 1rem;
text-align: center;
color: var(--text-color-secondary);
border: 1px dashed var(--surface-border);
border-radius: 6px;
background: var(--surface-ground);
}
</style>
</style>
@@ -0,0 +1,256 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/layout/configuracoes/ConfiguracoesRecursosExtrasPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const toast = useToast();
const tenantStore = useTenantStore();
// ── Contexto ──────────────────────────────────────────────────
const userId = ref(null);
const tenantId = ref(null);
const loading = ref(true);
// ── Produtos disponíveis ──────────────────────────────────────
const products = ref([]);
// ── Saldos do tenant ──────────────────────────────────────────
const creditsByType = ref({}); // { sms: { balance, total_purchased, ... }, email: { ... } }
// ── Dialog de interesse ───────────────────────────────────────
const interestDialog = ref(false);
const selectedProduct = ref(null);
// ── Helpers ───────────────────────────────────────────────────
function formatPrice(cents) {
if (!cents) return 'Grátis';
return (cents / 100).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function pricePerUnit(product) {
if (!product.credits_amount || !product.price_cents) return '';
const per = product.price_cents / product.credits_amount / 100;
return `R$ ${per.toFixed(2)} / crédito`;
}
function getBalance(addonType) {
return creditsByType.value[addonType]?.balance ?? 0;
}
function typeIcon(type) {
const map = { sms: 'pi pi-comment', email: 'pi pi-envelope', server: 'pi pi-server', domain: 'pi pi-globe' };
return map[type] || 'pi pi-box';
}
function typeLabel(type) {
const map = { sms: 'SMS', email: 'E-mail', server: 'Servidor', domain: 'Domínio' };
return map[type] || type;
}
// ── Load ──────────────────────────────────────────────────────
async function loadUser() {
const {
data: { user }
} = await supabase.auth.getUser();
if (!user) return;
userId.value = user.id;
tenantId.value = tenantStore.activeTenantId || user.id;
}
async function loadProducts() {
const { data } = await supabase.from('addon_products').select('*').eq('is_active', true).eq('is_visible', true).is('deleted_at', null).order('addon_type').order('sort_order');
if (data) products.value = data;
}
async function loadCredits() {
if (!tenantId.value) return;
const { data } = await supabase.from('addon_credits').select('addon_type, balance, total_purchased, total_consumed, expires_at').eq('tenant_id', tenantId.value).eq('is_active', true);
if (data) {
const map = {};
for (const c of data) map[c.addon_type] = c;
creditsByType.value = map;
}
}
// ── Interesse ─────────────────────────────────────────────────
function openInterest(product) {
selectedProduct.value = product;
interestDialog.value = true;
}
function confirmInterest() {
toast.add({
severity: 'success',
summary: 'Interesse registrado!',
detail: `Entre em contato com o suporte para adquirir "${selectedProduct.value?.name}". Em breve teremos compra online!`,
life: 6000
});
interestDialog.value = false;
}
// ── Produtos agrupados por tipo ───────────────────────────────
const productsByType = computed(() => {
const map = {};
for (const p of products.value) {
if (!map[p.addon_type]) map[p.addon_type] = [];
map[p.addon_type].push(p);
}
return map;
});
// ── Recursos futuros (estáticos) ──────────────────────────────
const futureAddons = [
{ type: 'server', name: 'Servidor Dedicado', desc: 'Infraestrutura exclusiva para sua clínica.', icon: 'pi pi-server' },
{ type: 'email', name: 'E-mail Avançado', desc: 'Domínio personalizado e caixa de e-mail profissional.', icon: 'pi pi-envelope' },
{ type: 'domain', name: 'Domínio Personalizado', desc: 'Acesse a plataforma pelo domínio da sua clínica.', icon: 'pi pi-globe' }
];
// ── Init ──────────────────────────────────────────────────────
onMounted(async () => {
await loadUser();
await Promise.all([loadProducts(), loadCredits()]);
loading.value = false;
});
</script>
<template>
<div class="flex flex-col gap-5">
<!-- Header -->
<Card>
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-box text-xl" />
Recursos Extras
</div>
</template>
<template #subtitle>Amplie as funcionalidades da sua clínica com recursos adicionais.</template>
</Card>
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-8 text-surface-500"><i class="pi pi-spin pi-spinner mr-2" /> Carregando recursos...</div>
<template v-else>
<!-- Produtos por tipo -->
<div v-for="(items, type) in productsByType" :key="type">
<h3 class="text-lg font-semibold mb-3 flex items-center gap-2">
<i :class="typeIcon(type)" />
{{ typeLabel(type) }}
<Tag v-if="getBalance(type) > 0" :value="`Saldo: ${getBalance(type)}`" severity="success" class="ml-2" />
<Tag v-else value="Sem créditos" severity="secondary" class="ml-2" />
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<Card v-for="product in items" :key="product.id" class="shadow-sm hover:shadow-md transition-shadow">
<template #title>
<div class="flex items-center gap-2 text-base">
<i :class="product.icon || typeIcon(product.addon_type)" />
{{ product.name }}
</div>
</template>
<template #content>
<p class="text-sm text-surface-500 mb-3">{{ product.description }}</p>
<div class="flex flex-col gap-1 mb-4">
<span class="text-2xl font-bold text-primary">{{ formatPrice(product.price_cents) }}</span>
<span v-if="product.credits_amount" class="text-xs text-surface-400"> {{ product.credits_amount }} créditos · {{ pricePerUnit(product) }} </span>
</div>
<Button label="Tenho interesse" icon="pi pi-shopping-cart" size="small" class="w-full" @click="openInterest(product)" />
</template>
</Card>
</div>
</div>
<!-- Sem produtos -->
<Message v-if="!products.length" severity="info" :closable="false"> Nenhum recurso extra disponível no momento. </Message>
<!-- Recursos futuros (Em breve) -->
<h3 class="text-lg font-semibold mt-4 mb-3 flex items-center gap-2">
<i class="pi pi-sparkles" />
Em breve
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card v-for="addon in futureAddons" :key="addon.type" class="opacity-60">
<template #title>
<div class="flex items-center gap-2 text-base">
<i :class="addon.icon" />
{{ addon.name }}
<Tag value="Em breve" severity="secondary" class="ml-auto" />
</div>
</template>
<template #content>
<p class="text-sm text-surface-500">{{ addon.desc }}</p>
</template>
</Card>
</div>
</template>
<!-- Dialog de interesse -->
<Dialog
v-model:visible="interestDialog"
modal
:draggable="false"
:closable="true"
:dismissableMask="true"
maximizable
class="dc-dialog w-[36rem]"
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
:pt="{
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
content: { class: '!p-3' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
}"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<div class="min-w-0">
<div class="text-base font-semibold truncate">Adquirir recurso</div>
<div class="text-xs opacity-50">Registre seu interesse neste recurso</div>
</div>
</div>
</div>
</template>
<div v-if="selectedProduct" class="flex flex-col gap-3">
<p>
Você deseja adquirir <strong>{{ selectedProduct.name }}</strong
>?
</p>
<p class="text-2xl font-bold text-primary">{{ formatPrice(selectedProduct.price_cents) }}</p>
<Message severity="info" :closable="false" class="text-sm"> A compra online estará disponível em breve. Por enquanto, entre em contato com o suporte ou administrador para ativar seus créditos. </Message>
</div>
<template #footer>
<div class="flex items-center justify-end gap-2 px-3 py-3">
<Button label="Cancelar" severity="secondary" text class="rounded-full hover:!text-red-500" @click="interestDialog = false" />
<Button label="Registrar interesse" icon="pi pi-check" class="rounded-full" @click="confirmInterest" />
</div>
</template>
</Dialog>
</div>
</template>
@@ -0,0 +1,497 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/layout/configuracoes/ConfiguracoesSmsPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { useRouter } from 'vue-router';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const toast = useToast();
const confirm = useConfirm();
const router = useRouter();
const tenantStore = useTenantStore();
// ── Contexto ──────────────────────────────────────────────────
const userId = ref(null);
const tenantId = ref(null);
const loading = ref(true);
// ── Saldo de créditos ─────────────────────────────────────────
const credits = ref(null);
const hasCredits = computed(() => credits.value !== null);
const balance = computed(() => credits.value?.balance ?? 0);
const totalPurchased = computed(() => credits.value?.total_purchased ?? 0);
const totalConsumed = computed(() => credits.value?.total_consumed ?? 0);
const balanceSeverity = computed(() => {
if (!hasCredits.value || balance.value <= 0) return 'danger';
if (balance.value <= (credits.value?.low_balance_threshold || 10)) return 'warn';
return 'success';
});
const balanceIcon = computed(() => {
if (balance.value <= 0) return 'pi pi-times-circle';
if (balance.value <= (credits.value?.low_balance_threshold || 10)) return 'pi pi-exclamation-triangle';
return 'pi pi-check-circle';
});
// ── Transações recentes ───────────────────────────────────────
const transactions = ref([]);
const txLoading = ref(false);
// ── Logs de envio ─────────────────────────────────────────────
const recentLogs = ref([]);
const logsLoading = ref(false);
// ══════════════════════════════════════════════════════════════
// Templates SMS
// ══════════════════════════════════════════════════════════════
const templates = ref([]);
const templatesLoading = ref(false);
const templateSaving = ref({});
// Labels para exibição
const EVENT_TYPE_LABELS = {
lembrete_sessao: 'Lembrete',
confirmacao_sessao: 'Confirmação',
cancelamento_sessao: 'Cancelamento',
reagendamento: 'Reagendamento',
cobranca_pendente: 'Financeiro',
boas_vindas_paciente: 'Boas-vindas',
intake_recebido: 'Triagem',
intake_aprovado: 'Triagem',
intake_rejeitado: 'Triagem'
};
const EVENT_SEVERITY = {
lembrete_sessao: 'info',
confirmacao_sessao: 'success',
cancelamento_sessao: 'danger',
reagendamento: 'warn',
cobranca_pendente: 'warn',
boas_vindas_paciente: 'success',
intake_recebido: 'info',
intake_aprovado: 'success',
intake_rejeitado: 'danger'
};
// Referências dos textareas por template key
const textareaRefs = ref({});
function setTextareaRef(key, el) {
if (el) textareaRefs.value[key] = el;
}
async function loadTemplates() {
if (!tenantId.value) return;
templatesLoading.value = true;
try {
// 1. Busca templates globais do SaaS (tenant_id IS NULL)
const { data: globals, error: gErr } = await supabase
.from('notification_templates')
.select('*')
.is('tenant_id', null)
.eq('channel', 'sms')
.eq('is_default', true)
.eq('is_active', true)
.is('deleted_at', null)
.order('domain')
.order('event_type');
if (gErr) throw gErr;
// 2. Busca customizações do tenant
const { data: customs, error: cErr } = await supabase.from('notification_templates').select('*').eq('tenant_id', tenantId.value).eq('channel', 'sms').is('deleted_at', null);
if (cErr) throw cErr;
const customMap = {};
for (const c of customs || []) customMap[c.key] = c;
// 3. Mescla: para cada global, verifica se o tenant tem customização
templates.value = (globals || []).map((g) => {
const custom = customMap[g.key];
return {
key: g.key,
domain: g.domain,
event_type: g.event_type,
label: EVENT_TYPE_LABELS[g.event_type] || g.event_type,
type: EVENT_TYPE_LABELS[g.event_type] || g.event_type,
type_severity: EVENT_SEVERITY[g.event_type] || 'secondary',
variables: g.variables || [],
default_body: g.body_text,
// Se tenant customizou, usa o texto dele; senão usa o global
id: custom?.id || null,
body_text: custom?.body_text || g.body_text,
is_custom: !!custom
};
});
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar templates', detail: e.message, life: 4000 });
} finally {
templatesLoading.value = false;
}
}
function insertVariable(templateKey, variable) {
const snippet = `{{${variable}}}`;
const tpl = templates.value.find((t) => t.key === templateKey);
if (!tpl) return;
const taWrapper = textareaRefs.value[templateKey];
const ta = taWrapper?.$el?.querySelector('textarea') ?? taWrapper;
if (ta?.setSelectionRange) {
const start = ta.selectionStart ?? ta.value.length;
const end = ta.selectionEnd ?? start;
tpl.body_text = (tpl.body_text || '').slice(0, start) + snippet + (tpl.body_text || '').slice(end);
nextTick(() => {
const pos = start + snippet.length;
ta.focus();
ta.setSelectionRange(pos, pos);
});
} else {
tpl.body_text = (tpl.body_text || '') + snippet;
}
}
async function saveTemplate(tpl) {
if (!tenantId.value || templateSaving.value[tpl.key]) return;
templateSaving.value[tpl.key] = true;
try {
if (tpl.id) {
const { error } = await supabase.from('notification_templates').update({ body_text: tpl.body_text }).eq('id', tpl.id);
if (error) throw error;
} else {
const { data: existing } = await supabase.from('notification_templates').select('id').eq('tenant_id', tenantId.value).eq('key', tpl.key).is('deleted_at', null).maybeSingle();
if (existing?.id) {
const { error } = await supabase.from('notification_templates').update({ body_text: tpl.body_text, is_active: true }).eq('id', existing.id);
if (error) throw error;
tpl.id = existing.id;
} else {
const { data, error } = await supabase
.from('notification_templates')
.insert({
owner_id: userId.value,
tenant_id: tenantId.value,
channel: 'sms',
key: tpl.key,
domain: tpl.domain,
event_type: tpl.event_type,
body_text: tpl.body_text,
variables: tpl.variables,
is_active: true,
is_default: false
})
.select('id')
.single();
if (error) throw error;
tpl.id = data.id;
}
tpl.is_custom = true;
}
toast.add({ severity: 'success', summary: 'Template salvo', life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar template', detail: e.message, life: 5000 });
} finally {
templateSaving.value[tpl.key] = false;
}
}
function isTemplateModified(tpl) {
return tpl.default_body ? tpl.body_text !== tpl.default_body : false;
}
function confirmRestoreTemplate(tpl) {
if (!tpl.default_body) return;
confirm.require({
group: 'headless',
message: `Restaurar "${tpl.label}" para o texto original definido pelo administrador?`,
header: 'Restaurar template',
icon: 'pi-undo',
accept: () => {
tpl.body_text = tpl.default_body;
}
});
}
// ── Load ──────────────────────────────────────────────────────
async function loadUser() {
const {
data: { user }
} = await supabase.auth.getUser();
if (!user) return;
userId.value = user.id;
tenantId.value = tenantStore.activeTenantId || user.id;
}
async function loadCredits() {
if (!tenantId.value) return;
const { data, error } = await supabase.from('addon_credits').select('*').eq('tenant_id', tenantId.value).eq('addon_type', 'sms').eq('is_active', true).maybeSingle();
if (!error && data) credits.value = data;
}
async function loadTransactions() {
if (!tenantId.value) return;
txLoading.value = true;
const { data } = await supabase
.from('addon_transactions')
.select('id, type, amount, balance_after, description, payment_method, created_at')
.eq('tenant_id', tenantId.value)
.eq('addon_type', 'sms')
.order('created_at', { ascending: false })
.limit(15);
txLoading.value = false;
if (data) transactions.value = data;
}
async function loadLogs() {
if (!tenantId.value) return;
logsLoading.value = true;
const { data } = await supabase
.from('notification_logs')
.select('id, template_key, recipient_address, status, failure_reason, sent_at, failed_at, created_at')
.eq('tenant_id', tenantId.value)
.eq('channel', 'sms')
.order('created_at', { ascending: false })
.limit(10);
logsLoading.value = false;
if (data) recentLogs.value = data;
}
// ── Helpers ───────────────────────────────────────────────────
function txTypeLabel(type) {
const map = { purchase: 'Compra', consume: 'Consumo', adjustment: 'Ajuste', refund: 'Reembolso', expiration: 'Expiração' };
return map[type] || type;
}
function txTypeSeverity(type) {
const map = { purchase: 'success', consume: 'secondary', adjustment: 'info', refund: 'warn', expiration: 'danger' };
return map[type] || 'secondary';
}
function logStatusSeverity(status) {
if (status === 'sent') return 'success';
if (status === 'failed') return 'danger';
return 'secondary';
}
function formatDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
}
function goToRecursosExtras() {
router.push('/configuracoes/recursos-extras');
}
// ── Init ──────────────────────────────────────────────────────
onMounted(async () => {
await loadUser();
await Promise.all([loadCredits(), loadTransactions(), loadLogs(), loadTemplates()]);
loading.value = false;
});
</script>
<template>
<div class="flex flex-col gap-5">
<ConfirmDialog group="headless">
<template #container="{ message, acceptCallback, rejectCallback }">
<div class="flex flex-col items-center p-8 bg-surface-0 dark:bg-surface-900 rounded-xl shadow-xl">
<div class="rounded-full inline-flex justify-center items-center h-24 w-24 -mt-20" :style="{ background: message.color || 'var(--p-primary-color)', color: '#fff' }">
<i :class="`pi ${message.icon || 'pi-question'} !text-4xl`"></i>
</div>
<span class="font-bold text-2xl block mb-2 mt-6">{{ message.header }}</span>
<p class="mb-0 text-center text-[var(--text-color-secondary)]">{{ message.message }}</p>
<div class="flex items-center gap-2 mt-6">
<Button label="Confirmar" class="rounded-full" :style="{ background: message.color || 'var(--p-primary-color)', borderColor: message.color || 'var(--p-primary-color)' }" @click="acceptCallback" />
<Button label="Cancelar" variant="outlined" class="rounded-full" @click="rejectCallback" />
</div>
</div>
</template>
</ConfirmDialog>
<!-- Saldo Card -->
<Card>
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-comment text-xl" />
Créditos SMS
</div>
</template>
<template #subtitle>Seus créditos para envio de SMS aos pacientes.</template>
<template #content>
<div v-if="loading" class="flex items-center gap-2 text-surface-500"><i class="pi pi-spin pi-spinner" /> Carregando...</div>
<div v-else class="flex flex-col gap-4">
<!-- Saldo principal -->
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<i :class="balanceIcon" :style="{ color: balanceSeverity === 'success' ? 'var(--p-green-500)' : balanceSeverity === 'warn' ? 'var(--p-yellow-500)' : 'var(--p-red-500)' }" class="text-2xl" />
<span class="text-4xl font-bold">{{ balance }}</span>
<span class="text-surface-500 text-sm">créditos disponíveis</span>
</div>
</div>
<!-- Estatísticas -->
<div v-if="hasCredits" class="flex gap-6 text-sm text-surface-500">
<div class="flex flex-col">
<span class="font-semibold text-surface-700">{{ totalPurchased }}</span>
<span>Total comprado</span>
</div>
<div class="flex flex-col">
<span class="font-semibold text-surface-700">{{ totalConsumed }}</span>
<span>Total consumido</span>
</div>
</div>
<!-- Alerta saldo baixo -->
<Message v-if="hasCredits && balance <= 0" severity="error" :closable="false"> Sem créditos SMS. Os lembretes por SMS estão pausados. Adquira mais créditos para reativar. </Message>
<Message v-else-if="hasCredits && balance <= (credits?.low_balance_threshold || 10)" severity="warn" :closable="false"> Saldo baixo! Restam apenas {{ balance }} créditos SMS. </Message>
<!-- Sem créditos ainda -->
<Message v-if="!hasCredits" severity="info" :closable="false"> Você ainda não possui créditos SMS. Adquira um pacote em Recursos Extras. </Message>
<Button label="Adquirir créditos SMS" icon="pi pi-shopping-cart" @click="goToRecursosExtras" class="w-fit" />
</div>
</template>
</Card>
<!-- -->
<!-- Templates de mensagem SMS -->
<!-- -->
<Card>
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-file-edit text-xl" />
Mensagens SMS
</div>
</template>
<template #subtitle>Personalize as mensagens enviadas por SMS aos seus pacientes. Os textos padrão funcionam edite apenas se quiser personalizar.</template>
<template #content>
<!-- Skeleton loading -->
<template v-if="templatesLoading">
<div v-for="n in 4" :key="n" class="border border-surface rounded-xl p-4 mb-3">
<div class="flex items-center gap-2 mb-3">
<Skeleton width="5rem" height="1.4rem" borderRadius="999px" />
<Skeleton width="10rem" height="1rem" />
</div>
<Skeleton width="100%" height="5rem" class="mb-2" />
<div class="flex gap-1">
<Skeleton v-for="i in 3" :key="i" width="6rem" height="1.6rem" borderRadius="999px" />
</div>
</div>
</template>
<!-- Cards de templates -->
<div v-else class="flex flex-col gap-4">
<div v-for="tpl in templates" :key="tpl.key" class="border border-surface rounded-xl p-4 flex flex-col gap-3">
<!-- Header do card -->
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold text-sm">{{ tpl.label }}</span>
<Tag :value="tpl.type" :severity="tpl.type_severity" class="text-[0.65rem]" />
<Tag v-if="tpl.is_custom" value="Personalizado" severity="success" class="text-[0.65rem]" />
</div>
<!-- Editor Jodit -->
<Textarea :ref="(el) => setTextareaRef(tpl.key, el)" v-model="tpl.body_text" rows="4" auto-resize class="w-full text-sm" />
<!-- Variáveis clicáveis -->
<div class="flex flex-col gap-1.5">
<span class="text-xs text-surface-500">Inserir variável:</span>
<div class="flex flex-wrap gap-1.5">
<Button v-for="v in tpl.variables" :key="v" size="small" severity="secondary" outlined class="font-mono !text-[0.68rem] !py-1 !px-2" @click="insertVariable(tpl.key, v)">
<span v-text="'{{' + v + '}}'"></span>
</Button>
</div>
</div>
<!-- Ações -->
<div class="flex items-center gap-2 justify-end">
<Button v-if="isTemplateModified(tpl)" label="Restaurar original" icon="pi pi-undo" size="small" severity="secondary" outlined @click="confirmRestoreTemplate(tpl)" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="templateSaving[tpl.key]" :disabled="templateSaving[tpl.key]" @click="saveTemplate(tpl)" />
</div>
</div>
</div>
</template>
</Card>
<!-- Histórico de transações -->
<Card>
<template #title>
<div class="flex items-center justify-between">
<span>Histórico de créditos</span>
<Button icon="pi pi-refresh" text rounded size="small" @click="loadTransactions" :loading="txLoading" />
</div>
</template>
<template #content>
<DataTable :value="transactions" :loading="txLoading" size="small" stripedRows emptyMessage="Nenhuma transação encontrada.">
<Column field="created_at" header="Data" style="width: 140px">
<template #body="{ data }">{{ formatDate(data.created_at) }}</template>
</Column>
<Column field="type" header="Tipo" style="width: 110px">
<template #body="{ data }">
<Tag :value="txTypeLabel(data.type)" :severity="txTypeSeverity(data.type)" />
</template>
</Column>
<Column field="amount" header="Qtd" style="width: 80px">
<template #body="{ data }">
<span :class="data.amount > 0 ? 'text-green-500 font-semibold' : 'text-red-500'"> {{ data.amount > 0 ? '+' : '' }}{{ data.amount }} </span>
</template>
</Column>
<Column field="balance_after" header="Saldo" style="width: 80px" />
<Column field="description" header="Descrição" />
</DataTable>
</template>
</Card>
<!-- Últimos envios -->
<Card>
<template #title>
<div class="flex items-center justify-between">
<span>Últimos envios SMS</span>
<Button icon="pi pi-refresh" text rounded size="small" @click="loadLogs" :loading="logsLoading" />
</div>
</template>
<template #content>
<DataTable :value="recentLogs" :loading="logsLoading" size="small" stripedRows emptyMessage="Nenhum registro de SMS encontrado.">
<Column field="created_at" header="Data" style="width: 140px">
<template #body="{ data }">{{ formatDate(data.sent_at || data.failed_at || data.created_at) }}</template>
</Column>
<Column field="template_key" header="Template" />
<Column field="recipient_address" header="Destinatário" />
<Column field="status" header="Status" style="width: 100px">
<template #body="{ data }">
<Tag :value="data.status" :severity="logStatusSeverity(data.status)" />
</template>
</Column>
<Column field="failure_reason" header="Erro" style="max-width: 200px">
<template #body="{ data }">
<span class="text-sm text-red-500 truncate block" :title="data.failure_reason">{{ data.failure_reason || '—' }}</span>
</template>
</Column>
</DataTable>
</template>
</Card>
</div>
</template>
@@ -0,0 +1,787 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/layout/configuracoes/ConfiguracoesWhatsappPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const toast = useToast();
const confirm = useConfirm();
const tenantStore = useTenantStore();
// ── Contexto ──────────────────────────────────────────────────
const userId = ref(null);
const tenantId = ref(null); // tenant_id real (da tabela tenants)
const activeTab = ref(0);
async function loadUser() {
const {
data: { user }
} = await supabase.auth.getUser();
if (!user) return;
userId.value = user.id;
// Usar o tenantId do store (tabela tenants), fallback para user.id
tenantId.value = tenantStore.activeTenantId || user.id;
}
// ══════════════════════════════════════════════════════════════
// ABA 1 — Conexão WhatsApp
// ══════════════════════════════════════════════════════════════
const credentials = ref({ api_url: '', api_key: '', instance_name: '' });
const hasCredentials = ref(false);
const connectionStatus = ref(null); // 'open' | 'close' | 'connecting' | null
const connectionLoading = ref(false);
// QR Code
const qrDialog = ref(false);
const qrCodeBase64 = ref(null);
const qrLoading = ref(false);
const qrCountdown = ref(0);
let qrTimer = null;
let isMounted = true;
const connectionTag = computed(() => {
if (connectionLoading.value) return { label: 'Verificando...', severity: 'secondary' };
if (!hasCredentials.value) return { label: 'Não configurado', severity: 'secondary' };
switch (connectionStatus.value) {
case 'open':
return { label: 'Conectado', severity: 'success' };
case 'connecting':
return { label: 'Conectando...', severity: 'warn' };
default:
return { label: 'Desconectado', severity: 'danger' };
}
});
// Carregar credenciais do banco — busca por tenant_id (consistente com SaaS)
// com fallback para owner_id (caso tenantId == userId)
async function loadCredentials() {
if (!tenantId.value) return;
// Tentar por tenant_id primeiro (como o SaaS salva)
let { data, error } = await supabase.from('notification_channels').select('*').eq('tenant_id', tenantId.value).eq('channel', 'whatsapp').is('deleted_at', null).maybeSingle();
// Fallback: buscar por owner_id (cenário legado ou tenant solo)
if (!data && userId.value && userId.value !== tenantId.value) {
const fallback = await supabase.from('notification_channels').select('*').eq('owner_id', userId.value).eq('channel', 'whatsapp').is('deleted_at', null).maybeSingle();
data = fallback.data;
error = fallback.error;
}
if (error) {
toast.add({ severity: 'error', summary: 'Erro ao carregar credenciais', detail: error.message, life: 4000 });
return;
}
if (data?.credentials) {
credentials.value = {
api_url: data.credentials.api_url || '',
api_key: data.credentials.api_key || '',
instance_name: data.credentials.instance_name || ''
};
hasCredentials.value = true;
}
}
// Verificar status da conexão via Evolution API
async function checkConnectionStatus() {
if (!hasCredentials.value) return;
connectionLoading.value = true;
try {
const res = await fetch(`${credentials.value.api_url}/instance/fetchInstances`, {
headers: { apikey: credentials.value.api_key }
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const instances = await res.json();
const inst = Array.isArray(instances) ? instances.find((i) => i.instance?.instanceName === credentials.value.instance_name) : null;
connectionStatus.value = inst?.instance?.status || 'close';
} catch (e) {
connectionStatus.value = 'close';
toast.add({
severity: 'warn',
summary: 'Não foi possível conectar à Evolution API',
detail: 'Verifique a URL e a chave de API.',
life: 5000
});
} finally {
connectionLoading.value = false;
}
}
// Buscar QR Code para conectar
async function fetchQrCode() {
if (!isMounted) return;
qrLoading.value = true;
qrCodeBase64.value = null;
clearQrTimer();
try {
const res = await fetch(`${credentials.value.api_url}/instance/connect/${credentials.value.instance_name}`, { headers: { apikey: credentials.value.api_key } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const base64 = data?.base64;
if (!base64) {
// Instância pode já estar conectada
if (data?.instance?.status === 'open') {
connectionStatus.value = 'open';
toast.add({ severity: 'success', summary: 'WhatsApp já está conectado!', life: 3000 });
qrDialog.value = false;
return;
}
throw new Error('QR Code não retornado pela API.');
}
qrCodeBase64.value = base64.startsWith('data:') ? base64 : `data:image/png;base64,${base64}`;
startQrCountdown();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao gerar QR Code', detail: e.message, life: 5000 });
} finally {
qrLoading.value = false;
}
}
function startQrCountdown() {
qrCountdown.value = 30;
qrTimer = setInterval(() => {
qrCountdown.value--;
if (qrCountdown.value <= 0) {
clearQrTimer();
fetchQrCode();
}
}, 1000);
}
function clearQrTimer() {
if (qrTimer) {
clearInterval(qrTimer);
qrTimer = null;
}
qrCountdown.value = 0;
}
function openQrDialog() {
qrDialog.value = true;
fetchQrCode();
}
function closeQrDialog() {
qrDialog.value = false;
clearQrTimer();
qrCodeBase64.value = null;
// Verificar se conectou depois de fechar o dialog
checkConnectionStatus();
}
// ══════════════════════════════════════════════════════════════
// ABA 2 — Templates de mensagem
// ══════════════════════════════════════════════════════════════
const templates = ref([]);
const templatesLoading = ref(false);
const templateSaving = ref({});
// Labels para exibição por event_type
const EVENT_TYPE_LABELS = {
lembrete_sessao: 'Lembrete',
confirmacao_sessao: 'Confirmação',
cancelamento_sessao: 'Cancelamento',
reagendamento: 'Reagendamento',
cobranca_pendente: 'Financeiro',
boas_vindas_paciente: 'Boas-vindas',
intake_recebido: 'Triagem',
intake_aprovado: 'Triagem'
};
const EVENT_SEVERITY = {
lembrete_sessao: 'info',
confirmacao_sessao: 'success',
cancelamento_sessao: 'danger',
reagendamento: 'warn',
cobranca_pendente: 'warning',
boas_vindas_paciente: 'success',
intake_recebido: 'info',
intake_aprovado: 'success'
};
// Label amigável a partir da key (ex: 'session.lembrete.whatsapp' → 'Lembrete de sessão')
function keyToLabel(key) {
const parts = key.replace('.whatsapp', '').split('.');
const map = {
'session.lembrete': 'Lembrete de sessão (24h antes)',
'session.lembrete_2h': 'Lembrete de sessão (2h antes)',
'session.confirmacao': 'Confirmação de agendamento',
'session.cancelamento': 'Sessão cancelada',
'session.reagendamento': 'Sessão reagendada',
'cobranca.pendente': 'Cobrança pendente',
'sistema.boas_vindas': 'Boas-vindas ao paciente'
};
return map[parts.slice(0, 2).join('.')] || key;
}
// Referências dos textareas para inserção no cursor
const textareaRefs = ref({});
function setTextareaRef(key, el) {
if (el) textareaRefs.value[key] = el;
}
async function loadTemplates() {
if (!tenantId.value) return;
templatesLoading.value = true;
try {
// 1. Busca templates globais do SaaS (tenant_id IS NULL)
const { data: globals, error: gErr } = await supabase
.from('notification_templates')
.select('*')
.is('tenant_id', null)
.eq('channel', 'whatsapp')
.eq('is_default', true)
.eq('is_active', true)
.is('deleted_at', null)
.order('domain')
.order('event_type');
if (gErr) throw gErr;
// 2. Busca customizações do tenant
const { data: customs, error: cErr } = await supabase.from('notification_templates').select('*').eq('tenant_id', tenantId.value).eq('channel', 'whatsapp').is('deleted_at', null);
if (cErr) throw cErr;
const customMap = {};
for (const c of customs || []) customMap[c.key] = c;
// 3. Mescla: para cada global, verifica se o tenant tem customização
templates.value = (globals || []).map((g) => {
const custom = customMap[g.key];
return {
key: g.key,
domain: g.domain,
event_type: g.event_type,
label: keyToLabel(g.key),
type: EVENT_TYPE_LABELS[g.event_type] || g.event_type,
type_severity: EVENT_SEVERITY[g.event_type] || 'secondary',
variables: g.variables || [],
default_body: g.body_text,
id: custom?.id || null,
body_text: custom?.body_text || g.body_text,
is_custom: !!custom
};
});
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar templates', detail: e.message, life: 4000 });
} finally {
templatesLoading.value = false;
}
}
// Inserir variável no textarea na posição do cursor
function insertVariable(templateKey, variable) {
const snippet = `{{${variable}}}`;
const tpl = templates.value.find((t) => t.key === templateKey);
if (!tpl) return;
const textarea = textareaRefs.value[templateKey]?.$el?.querySelector('textarea');
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = tpl.body_text;
tpl.body_text = text.substring(0, start) + snippet + text.substring(end);
nextTick(() => {
textarea.focus();
textarea.setSelectionRange(start + snippet.length, start + snippet.length);
});
} else {
// Fallback: adicionar ao final
tpl.body_text = (tpl.body_text || '') + snippet;
}
}
// Salvar template individual
async function saveTemplate(tpl) {
if (!tenantId.value || templateSaving.value[tpl.key]) return;
templateSaving.value[tpl.key] = true;
try {
if (tpl.id) {
// Atualizar existente
const { error } = await supabase.from('notification_templates').update({ body_text: tpl.body_text }).eq('id', tpl.id);
if (error) throw error;
} else {
// Verificar se já existe um registro ativo para esta key
const { data: existing } = await supabase.from('notification_templates').select('id').eq('tenant_id', tenantId.value).eq('key', tpl.key).is('deleted_at', null).maybeSingle();
if (existing?.id) {
// Já existe (criado por outra sessão) — atualizar
const { error } = await supabase.from('notification_templates').update({ body_text: tpl.body_text, is_active: true }).eq('id', existing.id);
if (error) throw error;
tpl.id = existing.id;
} else {
// Inserir novo
const { data, error } = await supabase
.from('notification_templates')
.insert({
owner_id: userId.value,
tenant_id: tenantId.value,
channel: 'whatsapp',
key: tpl.key,
domain: tpl.domain,
event_type: tpl.event_type,
body_text: tpl.body_text,
variables: tpl.variables,
is_active: true,
is_default: false
})
.select('id')
.single();
if (error) throw error;
tpl.id = data.id;
}
tpl.is_custom = true;
}
toast.add({ severity: 'success', summary: 'Template salvo', life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar template', detail: e.message, life: 5000 });
} finally {
templateSaving.value[tpl.key] = false;
}
}
// Verificar se template difere do padrão global
function isTemplateModified(tpl) {
return tpl.default_body ? tpl.body_text !== tpl.default_body : false;
}
// Restaurar template para o padrão global
function confirmRestoreTemplate(tpl) {
if (!tpl.default_body) return;
confirm.require({
group: 'headless',
message: `Restaurar "${tpl.label}" para o texto original definido pelo administrador?`,
header: 'Restaurar template',
icon: 'pi-undo',
accept: async () => {
tpl.body_text = tpl.default_body;
if (tpl.id) {
await saveTemplate(tpl);
}
}
});
}
// ══════════════════════════════════════════════════════════════
// ABA 3 — Logs de envio
// ══════════════════════════════════════════════════════════════
const logs = ref([]);
const logsLoading = ref(false);
const logsFilter = ref('todos');
const logsPage = ref(1);
const logsPerPage = 20;
const logsTotal = ref(0);
const FILTER_OPTIONS = [
{ label: 'Todos', value: 'todos' },
{ label: 'Enviado', value: 'sent' },
{ label: 'Falhou', value: 'failed' }
];
// Mapear keys para nomes amigáveis (dinâmico a partir dos templates carregados)
function friendlyTemplateKey(key) {
const tpl = templates.value.find((t) => t.key === key || t.event_type === key);
return tpl?.label || key || '—';
}
function statusTag(status) {
switch (status) {
case 'sent':
return { label: 'Enviado', severity: 'success' };
case 'failed':
return { label: 'Falhou', severity: 'danger' };
case 'pending':
return { label: 'Pendente', severity: 'warn' };
default:
return { label: status || '—', severity: 'secondary' };
}
}
function formatDate(dt) {
if (!dt) return '—';
const d = new Date(dt);
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' }) + ' ' + d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
async function loadLogs() {
if (!tenantId.value) return;
logsLoading.value = true;
try {
let query = supabase.from('notification_logs').select('*', { count: 'exact' }).eq('tenant_id', tenantId.value).eq('channel', 'whatsapp').order('created_at', { ascending: false });
if (logsFilter.value !== 'todos') {
query = query.eq('status', logsFilter.value);
}
const from = (logsPage.value - 1) * logsPerPage;
const to = from + logsPerPage - 1;
query = query.range(from, to);
const { data, error, count } = await query;
if (error) throw error;
logs.value = data || [];
logsTotal.value = count || 0;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar logs', detail: e.message, life: 4000 });
} finally {
logsLoading.value = false;
}
}
function onFilterChange(val) {
logsFilter.value = val;
logsPage.value = 1;
loadLogs();
}
function onPageChange(event) {
logsPage.value = event.page + 1;
loadLogs();
}
// ══════════════════════════════════════════════════════════════
// Inicialização
// ══════════════════════════════════════════════════════════════
onMounted(async () => {
await loadUser();
await Promise.all([loadCredentials(), loadTemplates(), loadLogs()]);
if (hasCredentials.value) await checkConnectionStatus();
});
onBeforeUnmount(() => {
isMounted = false;
clearQrTimer();
});
</script>
<template>
<div class="flex flex-col gap-4">
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-comments" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">WhatsApp</div>
<div class="cfg-subheader__sub">Configure a integração e os templates de mensagem do WhatsApp</div>
</div>
<div class="cfg-subheader__actions">
<Tag :value="connectionTag.label" :severity="connectionTag.severity" class="text-xs" />
</div>
</div>
<!-- Abas -->
<Tabs :value="activeTab" @update:value="activeTab = $event">
<TabList>
<Tab :value="0"><i class="pi pi-link mr-2" />Conexão</Tab>
<Tab :value="1"><i class="pi pi-file-edit mr-2" />Templates</Tab>
<Tab :value="2"><i class="pi pi-list mr-2" />Logs de envio</Tab>
</TabList>
<TabPanels>
<!-- ABA 1 Conexão -->
<TabPanel :value="0">
<div class="flex flex-col gap-4 pt-3">
<!-- Sem credenciais WhatsApp não configurado pelo admin -->
<div v-if="!hasCredentials" class="border border-[var(--surface-border)] rounded-lg p-6 bg-[var(--surface-card)] text-center">
<div class="grid place-items-center w-14 h-14 rounded-full bg-gray-100 text-gray-400 mx-auto mb-3">
<i class="pi pi-comments text-2xl" />
</div>
<div class="font-semibold text-sm mb-1">WhatsApp ainda não configurado</div>
<p class="text-sm text-[var(--text-color-secondary)] m-0 max-w-md mx-auto">
A integração com o WhatsApp precisa ser ativada pela equipe de suporte. Entre em contato para que possamos configurar o envio automático de mensagens para você.
</p>
</div>
<!-- Com credenciais: status + QR Code -->
<template v-else>
<!-- Status da conexão -->
<div class="border border-[var(--surface-border)] rounded-lg p-4 bg-[var(--surface-card)]">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3">
<div class="grid place-items-center w-10 h-10 rounded-full" :class="connectionStatus === 'open' ? 'bg-green-100 text-green-600' : 'bg-gray-100 text-gray-500'">
<i class="pi pi-comments text-lg" />
</div>
<div>
<div class="font-semibold text-sm">Status da conexão</div>
<Tag :value="connectionTag.label" :severity="connectionTag.severity" class="text-xs mt-1" />
</div>
</div>
<div class="flex gap-2">
<Button :label="connectionStatus === 'open' ? 'Reconectar' : 'Conectar WhatsApp'" icon="pi pi-qrcode" size="small" @click="openQrDialog" />
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="connectionLoading" v-tooltip.bottom="'Verificar status'" @click="checkConnectionStatus" />
</div>
</div>
</div>
<!-- Instruções simples para o terapeuta -->
<div v-if="connectionStatus !== 'open'" class="flex items-start gap-3 px-4 py-3 rounded-lg border border-[var(--surface-border)] bg-[var(--surface-ground)]">
<i class="pi pi-info-circle text-[var(--primary-color)] mt-0.5" />
<div class="text-sm text-[var(--text-color-secondary)]">
<strong class="text-[var(--text-color)]">Como conectar:</strong>
clique em <strong>"Conectar WhatsApp"</strong>, abra o WhatsApp no seu celular, em <strong>Configurações > Aparelhos conectados > Conectar aparelho</strong>
e escaneie o QR Code que aparecerá na tela.
</div>
</div>
</template>
</div>
</TabPanel>
<!-- ABA 2 Templates -->
<TabPanel :value="1">
<div class="flex flex-col gap-3 pt-3">
<!-- Skeleton loading -->
<template v-if="templatesLoading">
<div v-for="n in 4" :key="n" class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4">
<div class="flex items-center gap-2 mb-3">
<Skeleton width="5rem" height="1.4rem" border-radius="999px" />
<Skeleton width="10rem" height="1rem" />
</div>
<Skeleton width="100%" height="5rem" class="mb-2" />
<div class="flex gap-1">
<Skeleton v-for="i in 3" :key="i" width="6rem" height="1.6rem" border-radius="999px" />
</div>
</div>
</template>
<!-- Cards de templates -->
<div v-else v-for="tpl in templates" :key="tpl.key" class="border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] p-4 flex flex-col gap-3">
<!-- Header do card -->
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold text-sm">{{ tpl.label }}</span>
<Tag :value="tpl.type" :severity="tpl.type_severity" class="text-[0.65rem]" />
<Tag v-if="tpl.is_custom" value="Personalizado" severity="success" class="text-[0.65rem]" />
</div>
<!-- Textarea editável -->
<Textarea :ref="(el) => setTextareaRef(tpl.key, el)" v-model="tpl.body_text" rows="5" auto-resize class="w-full text-sm font-mono" />
<!-- Variáveis clicáveis -->
<div class="flex flex-col gap-1.5">
<span class="text-xs text-[var(--text-color-secondary)]">Inserir variável no cursor:</span>
<div class="flex flex-wrap gap-1.5">
<Button v-for="v in tpl.variables" :key="v" :label="`{{${v}}}`" size="small" severity="secondary" outlined class="font-mono !text-[0.68rem] !py-1 !px-2" @click="insertVariable(tpl.key, v)" />
</div>
</div>
<!-- Ações -->
<div class="flex items-center gap-2 justify-end">
<Button v-if="isTemplateModified(tpl)" label="Restaurar original" icon="pi pi-undo" size="small" severity="secondary" outlined @click="confirmRestoreTemplate(tpl)" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="templateSaving[tpl.key]" :disabled="templateSaving[tpl.key]" @click="saveTemplate(tpl)" />
</div>
</div>
</div>
</TabPanel>
<!-- ABA 3 Logs -->
<TabPanel :value="2">
<div class="flex flex-col gap-3 pt-3">
<!-- Filtros -->
<div class="flex items-center gap-2 flex-wrap">
<Button
v-for="opt in FILTER_OPTIONS"
:key="opt.value"
:label="opt.label"
size="small"
:severity="logsFilter === opt.value ? 'primary' : 'secondary'"
:outlined="logsFilter !== opt.value"
@click="onFilterChange(opt.value)"
/>
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="logsLoading" v-tooltip.bottom="'Atualizar'" class="ml-auto" @click="loadLogs" />
</div>
<!-- Tabela -->
<DataTable :value="logs" :loading="logsLoading" responsive-layout="scroll" striped-rows class="text-sm">
<Column field="created_at" header="Data/hora" style="min-width: 140px">
<template #body="{ data }">
{{ formatDate(data.created_at) }}
</template>
</Column>
<Column field="recipient_address" header="Destinatário" style="min-width: 140px" />
<Column field="template_key" header="Template" style="min-width: 160px">
<template #body="{ data }">
{{ friendlyTemplateKey(data.template_key) }}
</template>
</Column>
<Column field="status" header="Status" style="min-width: 100px">
<template #body="{ data }">
<Tag :value="statusTag(data.status).label" :severity="statusTag(data.status).severity" class="text-[0.7rem]" />
</template>
</Column>
<Column field="failure_reason" header="Erro" style="min-width: 160px">
<template #body="{ data }">
<span v-if="data.failure_reason" v-tooltip.top="data.failure_reason" class="text-xs text-[var(--text-color-secondary)] truncate block max-w-[200px]">
{{ data.failure_reason }}
</span>
<span v-else class="text-xs text-[var(--text-color-secondary)] opacity-40"></span>
</template>
</Column>
<template #empty>
<div class="text-center py-6 text-sm text-[var(--text-color-secondary)]">Nenhum log de envio encontrado.</div>
</template>
</DataTable>
<!-- Paginação -->
<Paginator v-if="logsTotal > logsPerPage" :rows="logsPerPage" :totalRecords="logsTotal" :first="(logsPage - 1) * logsPerPage" @page="onPageChange" />
</div>
</TabPanel>
</TabPanels>
</Tabs>
<!-- Dialog QR Code -->
<Dialog
v-model:visible="qrDialog"
modal
:draggable="false"
:closable="!qrLoading"
:dismissableMask="!qrLoading"
maximizable
class="dc-dialog w-[36rem]"
:breakpoints="{ '1199px': '90vw', '768px': '94vw' }"
:pt="{
header: { class: '!p-3 !rounded-t-[12px] border-b border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
content: { class: '!p-3' },
footer: { class: '!p-0 !rounded-b-[12px] border-t border-[var(--surface-border)] shadow-[0_1px_0_0_rgba(255,255,255,0.06)] bg-gray-100' },
pcCloseButton: { root: { class: '!rounded-md hover:!text-red-500' } },
pcMaximizeButton: { root: { class: '!rounded-md hover:!text-primary' } }
}"
pt:mask:class="backdrop-blur-xs"
@hide="closeQrDialog"
>
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<div class="min-w-0">
<div class="text-base font-semibold truncate">Conectar WhatsApp</div>
<div class="text-xs opacity-50">Escaneie o QR Code para conectar</div>
</div>
</div>
</div>
</template>
<div class="flex flex-col items-center gap-4 py-2">
<p class="text-sm text-[var(--text-color-secondary)] text-center m-0">Escaneie o QR Code abaixo com o WhatsApp do seu celular para conectar.</p>
<!-- Loading -->
<div v-if="qrLoading" class="flex flex-col items-center gap-3 py-6">
<ProgressSpinner style="width: 48px; height: 48px" />
<span class="text-xs text-[var(--text-color-secondary)]">Gerando QR Code...</span>
</div>
<!-- QR Code -->
<div v-else-if="qrCodeBase64" class="flex flex-col items-center gap-3">
<img :src="qrCodeBase64" alt="QR Code WhatsApp" class="w-64 h-64 rounded-lg border border-[var(--surface-border)]" />
<div class="flex items-center gap-2 text-xs text-[var(--text-color-secondary)]">
<i class="pi pi-clock" />
<span
>Atualiza automaticamente em <strong>{{ qrCountdown }}s</strong></span
>
</div>
</div>
<!-- Erro / sem QR -->
<div v-else class="text-center py-6">
<i class="pi pi-exclamation-circle text-3xl text-[var(--text-color-secondary)] opacity-40 mb-2" />
<p class="text-sm text-[var(--text-color-secondary)] m-0">Não foi possível gerar o QR Code.</p>
</div>
<Button label="Atualizar QR Code" icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="qrLoading" @click="fetchQrCode" />
</div>
<template #footer>
<div class="flex items-center justify-end gap-2 px-3 py-3">
<Button label="Fechar" severity="secondary" text class="rounded-full" @click="closeQrDialog" />
</div>
</template>
</Dialog>
<ConfirmDialog group="headless">
<template #container="{ message, acceptCallback, rejectCallback }">
<div class="flex flex-col items-center p-8 bg-surface-0 dark:bg-surface-900 rounded-xl shadow-xl">
<div class="rounded-full inline-flex justify-center items-center h-24 w-24 -mt-20" :style="{ background: message.color || 'var(--p-primary-color)', color: '#fff' }">
<i :class="`pi ${message.icon || 'pi-question'} !text-4xl`"></i>
</div>
<span class="font-bold text-2xl block mb-2 mt-6">{{ message.header }}</span>
<p class="mb-0 text-center text-[var(--text-color-secondary)]">{{ message.message }}</p>
<div class="flex items-center gap-2 mt-6">
<Button label="Confirmar" class="rounded-full" :style="{ background: message.color || 'var(--p-primary-color)', borderColor: message.color || 'var(--p-primary-color)' }" @click="acceptCallback" />
<Button label="Cancelar" variant="outlined" class="rounded-full" @click="rejectCallback" />
</div>
</div>
</template>
</ConfirmDialog>
</div>
</template>
<style scoped>
.cfg-subheader {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.875rem 1rem;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%);
position: relative;
overflow: hidden;
}
.cfg-subheader::before {
content: '';
position: absolute;
top: -20px;
right: -20px;
width: 80px;
height: 80px;
border-radius: 50%;
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
filter: blur(20px);
pointer-events: none;
}
.cfg-subheader__icon {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 6px;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
color: var(--primary-color, #6366f1);
font-size: 0.85rem;
}
.cfg-subheader__title {
font-size: 0.95rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--primary-color, #6366f1);
}
.cfg-subheader__sub {
font-size: 0.75rem;
color: var(--text-color-secondary);
opacity: 0.85;
}
.cfg-subheader__actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
flex-shrink: 0;
position: relative;
z-index: 1;
}
</style>