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
+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>