first commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<AppShellLayout />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppShellLayout from './AppShellLayout.vue'
|
||||
</script>
|
||||
+100
-224
@@ -1,243 +1,119 @@
|
||||
<script setup>
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import { $t, updatePreset, updateSurfacePalette } from '@primeuix/themes';
|
||||
import Aura from '@primeuix/themes/aura';
|
||||
import Lara from '@primeuix/themes/lara';
|
||||
import Nora from '@primeuix/themes/nora';
|
||||
import { ref } from 'vue';
|
||||
import { computed, inject } from 'vue'
|
||||
import { useLayout } from '@/layout/composables/layout'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
|
||||
const { layoutConfig, isDarkTheme, changeMenuMode } = useLayout();
|
||||
import { primaryColors, surfaces, presetOptions, applyThemeEngine } from '@/theme/theme.options'
|
||||
|
||||
const presets = {
|
||||
Aura,
|
||||
Lara,
|
||||
Nora
|
||||
};
|
||||
const preset = ref(layoutConfig.preset);
|
||||
const presetOptions = ref(Object.keys(presets));
|
||||
const { layoutConfig, isDarkTheme, changeMenuMode } = useLayout()
|
||||
|
||||
const menuMode = ref(layoutConfig.menuMode);
|
||||
const menuModeOptions = ref([
|
||||
{ label: 'Static', value: 'static' },
|
||||
{ label: 'Overlay', value: 'overlay' }
|
||||
]);
|
||||
// ✅ vem do AppTopbar (mesma instância)
|
||||
const queuePatch = inject('queueUserSettingsPatch', null)
|
||||
console.log('[AppConfigurator] queuePatch injected?', !!queuePatch)
|
||||
|
||||
const primaryColors = ref([
|
||||
{ name: 'noir', palette: {} },
|
||||
{ name: 'emerald', palette: { 50: '#ecfdf5', 100: '#d1fae5', 200: '#a7f3d0', 300: '#6ee7b7', 400: '#34d399', 500: '#10b981', 600: '#059669', 700: '#047857', 800: '#065f46', 900: '#064e3b', 950: '#022c22' } },
|
||||
{ name: 'green', palette: { 50: '#f0fdf4', 100: '#dcfce7', 200: '#bbf7d0', 300: '#86efac', 400: '#4ade80', 500: '#22c55e', 600: '#16a34a', 700: '#15803d', 800: '#166534', 900: '#14532d', 950: '#052e16' } },
|
||||
{ name: 'lime', palette: { 50: '#f7fee7', 100: '#ecfccb', 200: '#d9f99d', 300: '#bef264', 400: '#a3e635', 500: '#84cc16', 600: '#65a30d', 700: '#4d7c0f', 800: '#3f6212', 900: '#365314', 950: '#1a2e05' } },
|
||||
{ name: 'orange', palette: { 50: '#fff7ed', 100: '#ffedd5', 200: '#fed7aa', 300: '#fdba74', 400: '#fb923c', 500: '#f97316', 600: '#ea580c', 700: '#c2410c', 800: '#9a3412', 900: '#7c2d12', 950: '#431407' } },
|
||||
{ name: 'amber', palette: { 50: '#fffbeb', 100: '#fef3c7', 200: '#fde68a', 300: '#fcd34d', 400: '#fbbf24', 500: '#f59e0b', 600: '#d97706', 700: '#b45309', 800: '#92400e', 900: '#78350f', 950: '#451a03' } },
|
||||
{ name: 'yellow', palette: { 50: '#fefce8', 100: '#fef9c3', 200: '#fef08a', 300: '#fde047', 400: '#facc15', 500: '#eab308', 600: '#ca8a04', 700: '#a16207', 800: '#854d0e', 900: '#713f12', 950: '#422006' } },
|
||||
{ name: 'teal', palette: { 50: '#f0fdfa', 100: '#ccfbf1', 200: '#99f6e4', 300: '#5eead4', 400: '#2dd4bf', 500: '#14b8a6', 600: '#0d9488', 700: '#0f766e', 800: '#115e59', 900: '#134e4a', 950: '#042f2e' } },
|
||||
{ name: 'cyan', palette: { 50: '#ecfeff', 100: '#cffafe', 200: '#a5f3fc', 300: '#67e8f9', 400: '#22d3ee', 500: '#06b6d4', 600: '#0891b2', 700: '#0e7490', 800: '#155e75', 900: '#164e63', 950: '#083344' } },
|
||||
{ name: 'sky', palette: { 50: '#f0f9ff', 100: '#e0f2fe', 200: '#bae6fd', 300: '#7dd3fc', 400: '#38bdf8', 500: '#0ea5e9', 600: '#0284c7', 700: '#0369a1', 800: '#075985', 900: '#0c4a6e', 950: '#082f49' } },
|
||||
{ name: 'blue', palette: { 50: '#eff6ff', 100: '#dbeafe', 200: '#bfdbfe', 300: '#93c5fd', 400: '#60a5fa', 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8', 800: '#1e40af', 900: '#1e3a8a', 950: '#172554' } },
|
||||
{ name: 'indigo', palette: { 50: '#eef2ff', 100: '#e0e7ff', 200: '#c7d2fe', 300: '#a5b4fc', 400: '#818cf8', 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca', 800: '#3730a3', 900: '#312e81', 950: '#1e1b4b' } },
|
||||
{ name: 'violet', palette: { 50: '#f5f3ff', 100: '#ede9fe', 200: '#ddd6fe', 300: '#c4b5fd', 400: '#a78bfa', 500: '#8b5cf6', 600: '#7c3aed', 700: '#6d28d9', 800: '#5b21b6', 900: '#4c1d95', 950: '#2e1065' } },
|
||||
{ name: 'purple', palette: { 50: '#faf5ff', 100: '#f3e8ff', 200: '#e9d5ff', 300: '#d8b4fe', 400: '#c084fc', 500: '#a855f7', 600: '#9333ea', 700: '#7e22ce', 800: '#6b21a8', 900: '#581c87', 950: '#3b0764' } },
|
||||
{ name: 'fuchsia', palette: { 50: '#fdf4ff', 100: '#fae8ff', 200: '#f5d0fe', 300: '#f0abfc', 400: '#e879f9', 500: '#d946ef', 600: '#c026d3', 700: '#a21caf', 800: '#86198f', 900: '#701a75', 950: '#4a044e' } },
|
||||
{ name: 'pink', palette: { 50: '#fdf2f8', 100: '#fce7f3', 200: '#fbcfe8', 300: '#f9a8d4', 400: '#f472b6', 500: '#ec4899', 600: '#db2777', 700: '#be185d', 800: '#9d174d', 900: '#831843', 950: '#500724' } },
|
||||
{ name: 'rose', palette: { 50: '#fff1f2', 100: '#ffe4e6', 200: '#fecdd3', 300: '#fda4af', 400: '#fb7185', 500: '#f43f5e', 600: '#e11d48', 700: '#be123c', 800: '#9f1239', 900: '#881337', 950: '#4c0519' } }
|
||||
]);
|
||||
// menu mode options
|
||||
const menuModeOptions = [
|
||||
{ label: 'Static', value: 'static' },
|
||||
{ label: 'Overlay', value: 'overlay' }
|
||||
]
|
||||
|
||||
const surfaces = ref([
|
||||
{
|
||||
name: 'slate',
|
||||
palette: { 0: '#ffffff', 50: '#f8fafc', 100: '#f1f5f9', 200: '#e2e8f0', 300: '#cbd5e1', 400: '#94a3b8', 500: '#64748b', 600: '#475569', 700: '#334155', 800: '#1e293b', 900: '#0f172a', 950: '#020617' }
|
||||
},
|
||||
{
|
||||
name: 'gray',
|
||||
palette: { 0: '#ffffff', 50: '#f9fafb', 100: '#f3f4f6', 200: '#e5e7eb', 300: '#d1d5db', 400: '#9ca3af', 500: '#6b7280', 600: '#4b5563', 700: '#374151', 800: '#1f2937', 900: '#111827', 950: '#030712' }
|
||||
},
|
||||
{
|
||||
name: 'zinc',
|
||||
palette: { 0: '#ffffff', 50: '#fafafa', 100: '#f4f4f5', 200: '#e4e4e7', 300: '#d4d4d8', 400: '#a1a1aa', 500: '#71717a', 600: '#52525b', 700: '#3f3f46', 800: '#27272a', 900: '#18181b', 950: '#09090b' }
|
||||
},
|
||||
{
|
||||
name: 'neutral',
|
||||
palette: { 0: '#ffffff', 50: '#fafafa', 100: '#f5f5f5', 200: '#e5e5e5', 300: '#d4d4d4', 400: '#a3a3a3', 500: '#737373', 600: '#525252', 700: '#404040', 800: '#262626', 900: '#171717', 950: '#0a0a0a' }
|
||||
},
|
||||
{
|
||||
name: 'stone',
|
||||
palette: { 0: '#ffffff', 50: '#fafaf9', 100: '#f5f5f4', 200: '#e7e5e4', 300: '#d6d3d1', 400: '#a8a29e', 500: '#78716c', 600: '#57534e', 700: '#44403c', 800: '#292524', 900: '#1c1917', 950: '#0c0a09' }
|
||||
},
|
||||
{
|
||||
name: 'soho',
|
||||
palette: { 0: '#ffffff', 50: '#f4f4f4', 100: '#e8e9e9', 200: '#d2d2d4', 300: '#bbbcbe', 400: '#a5a5a9', 500: '#8e8f93', 600: '#77787d', 700: '#616268', 800: '#4a4b52', 900: '#34343d', 950: '#1d1e27' }
|
||||
},
|
||||
{
|
||||
name: 'viva',
|
||||
palette: { 0: '#ffffff', 50: '#f3f3f3', 100: '#e7e7e8', 200: '#cfd0d0', 300: '#b7b8b9', 400: '#9fa1a1', 500: '#87898a', 600: '#6e7173', 700: '#565a5b', 800: '#3e4244', 900: '#262b2c', 950: '#0e1315' }
|
||||
},
|
||||
{
|
||||
name: 'ocean',
|
||||
palette: { 0: '#ffffff', 50: '#fbfcfc', 100: '#F7F9F8', 200: '#EFF3F2', 300: '#DADEDD', 400: '#B1B7B6', 500: '#828787', 600: '#5F7274', 700: '#415B61', 800: '#29444E', 900: '#183240', 950: '#0c1920' }
|
||||
}
|
||||
]);
|
||||
// ✅ v-model sincronizado (sem state local)
|
||||
const presetModel = computed({
|
||||
get: () => layoutConfig.preset,
|
||||
set: (val) => {
|
||||
if (!val || val === layoutConfig.preset) return
|
||||
layoutConfig.preset = val
|
||||
|
||||
function getPresetExt() {
|
||||
const color = primaryColors.value.find((c) => c.name === layoutConfig.primary);
|
||||
applyThemeEngine(layoutConfig)
|
||||
queuePatch?.({ preset: val })
|
||||
}
|
||||
})
|
||||
|
||||
if (color.name === 'noir') {
|
||||
return {
|
||||
semantic: {
|
||||
primary: {
|
||||
50: '{surface.50}',
|
||||
100: '{surface.100}',
|
||||
200: '{surface.200}',
|
||||
300: '{surface.300}',
|
||||
400: '{surface.400}',
|
||||
500: '{surface.500}',
|
||||
600: '{surface.600}',
|
||||
700: '{surface.700}',
|
||||
800: '{surface.800}',
|
||||
900: '{surface.900}',
|
||||
950: '{surface.950}'
|
||||
},
|
||||
colorScheme: {
|
||||
light: {
|
||||
primary: {
|
||||
color: '{primary.950}',
|
||||
contrastColor: '#ffffff',
|
||||
hoverColor: '{primary.800}',
|
||||
activeColor: '{primary.700}'
|
||||
},
|
||||
highlight: {
|
||||
background: '{primary.950}',
|
||||
focusBackground: '{primary.700}',
|
||||
color: '#ffffff',
|
||||
focusColor: '#ffffff'
|
||||
}
|
||||
},
|
||||
dark: {
|
||||
primary: {
|
||||
color: '{primary.50}',
|
||||
contrastColor: '{primary.950}',
|
||||
hoverColor: '{primary.200}',
|
||||
activeColor: '{primary.300}'
|
||||
},
|
||||
highlight: {
|
||||
background: '{primary.50}',
|
||||
focusBackground: '{primary.300}',
|
||||
color: '{primary.950}',
|
||||
focusColor: '{primary.950}'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
semantic: {
|
||||
primary: color.palette,
|
||||
colorScheme: {
|
||||
light: {
|
||||
primary: {
|
||||
color: '{primary.500}',
|
||||
contrastColor: '#ffffff',
|
||||
hoverColor: '{primary.600}',
|
||||
activeColor: '{primary.700}'
|
||||
},
|
||||
highlight: {
|
||||
background: '{primary.50}',
|
||||
focusBackground: '{primary.100}',
|
||||
color: '{primary.700}',
|
||||
focusColor: '{primary.800}'
|
||||
}
|
||||
},
|
||||
dark: {
|
||||
primary: {
|
||||
color: '{primary.400}',
|
||||
contrastColor: '{surface.900}',
|
||||
hoverColor: '{primary.300}',
|
||||
activeColor: '{primary.200}'
|
||||
},
|
||||
highlight: {
|
||||
background: 'color-mix(in srgb, {primary.400}, transparent 84%)',
|
||||
focusBackground: 'color-mix(in srgb, {primary.400}, transparent 76%)',
|
||||
color: 'rgba(255,255,255,.87)',
|
||||
focusColor: 'rgba(255,255,255,.87)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
const menuModeModel = computed({
|
||||
get: () => layoutConfig.menuMode,
|
||||
set: (val) => {
|
||||
if (!val || val === layoutConfig.menuMode) return
|
||||
layoutConfig.menuMode = val
|
||||
|
||||
function updateColors(type, color) {
|
||||
if (type === 'primary') {
|
||||
layoutConfig.primary = color.name;
|
||||
} else if (type === 'surface') {
|
||||
layoutConfig.surface = color.name;
|
||||
}
|
||||
// composable pode aceitar nada (no teu caso, costuma ser isso)
|
||||
try { changeMenuMode() } catch {}
|
||||
|
||||
applyTheme(type, color);
|
||||
}
|
||||
queuePatch?.({ menu_mode: val })
|
||||
}
|
||||
})
|
||||
|
||||
function applyTheme(type, color) {
|
||||
if (type === 'primary') {
|
||||
updatePreset(getPresetExt());
|
||||
} else if (type === 'surface') {
|
||||
updateSurfacePalette(color.palette);
|
||||
}
|
||||
}
|
||||
function updateColors(type, item) {
|
||||
if (type === 'primary') {
|
||||
layoutConfig.primary = item.name
|
||||
applyThemeEngine(layoutConfig)
|
||||
queuePatch?.({ primary_color: item.name })
|
||||
return
|
||||
}
|
||||
|
||||
function onPresetChange() {
|
||||
layoutConfig.preset = preset.value;
|
||||
const presetValue = presets[preset.value];
|
||||
const surfacePalette = surfaces.value.find((s) => s.name === layoutConfig.surface)?.palette;
|
||||
|
||||
$t().preset(presetValue).preset(getPresetExt()).surfacePalette(surfacePalette).use({ useDefaultOptions: true });
|
||||
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="primaryColor of primaryColors"
|
||||
:key="primaryColor.name"
|
||||
type="button"
|
||||
:title="primaryColor.name"
|
||||
@click="updateColors('primary', primaryColor)"
|
||||
:class="['border-none w-5 h-5 rounded-full p-0 cursor-pointer outline-none outline-offset-1', { 'outline-primary': layoutConfig.primary === primaryColor.name }]"
|
||||
:style="{ backgroundColor: `${primaryColor.name === 'noir' ? 'var(--text-color)' : primaryColor.palette['500']}` }"
|
||||
></button>
|
||||
</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="surface of surfaces"
|
||||
:key="surface.name"
|
||||
type="button"
|
||||
:title="surface.name"
|
||||
@click="updateColors('surface', surface)"
|
||||
:class="[
|
||||
'border-none w-5 h-5 rounded-full p-0 cursor-pointer outline-none outline-offset-1',
|
||||
{ 'outline-primary': layoutConfig.surface ? layoutConfig.surface === surface.name : isDarkTheme ? surface.name === 'zinc' : surface.name === 'slate' }
|
||||
]"
|
||||
:style="{ backgroundColor: `${surface.palette['500']}` }"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm text-muted-color font-semibold">Presets</span>
|
||||
<SelectButton v-model="preset" @change="onPresetChange" :options="presetOptions" :allowEmpty="false" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm text-muted-color font-semibold">Menu Mode</span>
|
||||
<SelectButton v-model="menuMode" @change="changeMenuMode" :options="menuModeOptions" :allowEmpty="false" optionLabel="label" optionValue="value" />
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+476
-264
@@ -1,271 +1,483 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import AppMenuItem from './AppMenuItem.vue';
|
||||
import { computed, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useLayout } from '@/layout/composables/layout'
|
||||
|
||||
const model = ref([
|
||||
{
|
||||
label: 'Home',
|
||||
items: [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
icon: 'pi pi-fw pi-home',
|
||||
to: '/'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'UI Components',
|
||||
path: '/uikit',
|
||||
items: [
|
||||
{
|
||||
label: 'Form Layout',
|
||||
icon: 'pi pi-fw pi-id-card',
|
||||
to: '/uikit/formlayout'
|
||||
},
|
||||
{
|
||||
label: 'Input',
|
||||
icon: 'pi pi-fw pi-check-square',
|
||||
to: '/uikit/input'
|
||||
},
|
||||
{
|
||||
label: 'Button',
|
||||
icon: 'pi pi-fw pi-mobile',
|
||||
to: '/uikit/button',
|
||||
class: 'rotated-icon'
|
||||
},
|
||||
{
|
||||
label: 'Table',
|
||||
icon: 'pi pi-fw pi-table',
|
||||
to: '/uikit/table'
|
||||
},
|
||||
{
|
||||
label: 'List',
|
||||
icon: 'pi pi-fw pi-list',
|
||||
to: '/uikit/list'
|
||||
},
|
||||
{
|
||||
label: 'Tree',
|
||||
icon: 'pi pi-fw pi-share-alt',
|
||||
to: '/uikit/tree'
|
||||
},
|
||||
{
|
||||
label: 'Panel',
|
||||
icon: 'pi pi-fw pi-tablet',
|
||||
to: '/uikit/panel'
|
||||
},
|
||||
{
|
||||
label: 'Overlay',
|
||||
icon: 'pi pi-fw pi-clone',
|
||||
to: '/uikit/overlay'
|
||||
},
|
||||
{
|
||||
label: 'Media',
|
||||
icon: 'pi pi-fw pi-image',
|
||||
to: '/uikit/media'
|
||||
},
|
||||
{
|
||||
label: 'Menu',
|
||||
icon: 'pi pi-fw pi-bars',
|
||||
to: '/uikit/menu'
|
||||
},
|
||||
{
|
||||
label: 'Message',
|
||||
icon: 'pi pi-fw pi-comment',
|
||||
to: '/uikit/message'
|
||||
},
|
||||
{
|
||||
label: 'File',
|
||||
icon: 'pi pi-fw pi-file',
|
||||
to: '/uikit/file'
|
||||
},
|
||||
{
|
||||
label: 'Chart',
|
||||
icon: 'pi pi-fw pi-chart-bar',
|
||||
to: '/uikit/charts'
|
||||
},
|
||||
{
|
||||
label: 'Timeline',
|
||||
icon: 'pi pi-fw pi-calendar',
|
||||
to: '/uikit/timeline'
|
||||
},
|
||||
{
|
||||
label: 'Misc',
|
||||
icon: 'pi pi-fw pi-circle',
|
||||
to: '/uikit/misc'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Prime Blocks',
|
||||
icon: 'pi pi-fw pi-prime',
|
||||
path: '/blocks',
|
||||
items: [
|
||||
{
|
||||
label: 'Free Blocks',
|
||||
icon: 'pi pi-fw pi-eye',
|
||||
to: '/blocks/free'
|
||||
},
|
||||
{
|
||||
label: 'All Blocks',
|
||||
icon: 'pi pi-fw pi-globe',
|
||||
url: 'https://blocks.primevue.org/',
|
||||
target: '_blank'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Pages',
|
||||
icon: 'pi pi-fw pi-briefcase',
|
||||
path: '/pages',
|
||||
items: [
|
||||
{
|
||||
label: 'Landing',
|
||||
icon: 'pi pi-fw pi-globe',
|
||||
to: '/landing'
|
||||
},
|
||||
{
|
||||
label: 'Auth',
|
||||
icon: 'pi pi-fw pi-user',
|
||||
path: '/auth',
|
||||
items: [
|
||||
{
|
||||
label: 'Login',
|
||||
icon: 'pi pi-fw pi-sign-in',
|
||||
to: '/auth/login'
|
||||
},
|
||||
{
|
||||
label: 'Error',
|
||||
icon: 'pi pi-fw pi-times-circle',
|
||||
to: '/auth/error'
|
||||
},
|
||||
{
|
||||
label: 'Access Denied',
|
||||
icon: 'pi pi-fw pi-lock',
|
||||
to: '/auth/access'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Crud',
|
||||
icon: 'pi pi-fw pi-pencil',
|
||||
to: '/pages/crud'
|
||||
},
|
||||
{
|
||||
label: 'Not Found',
|
||||
icon: 'pi pi-fw pi-exclamation-circle',
|
||||
to: '/pages/notfound'
|
||||
},
|
||||
{
|
||||
label: 'Empty',
|
||||
icon: 'pi pi-fw pi-circle-off',
|
||||
to: '/pages/empty'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Hierarchy',
|
||||
icon: 'pi pi-fw pi-align-left',
|
||||
path: '/hierarchy',
|
||||
items: [
|
||||
{
|
||||
label: 'Submenu 1',
|
||||
icon: 'pi pi-fw pi-align-left',
|
||||
path: '/submenu_1',
|
||||
items: [
|
||||
{
|
||||
label: 'Submenu 1.1',
|
||||
icon: 'pi pi-fw pi-align-left',
|
||||
path: '/submenu_1_1',
|
||||
items: [
|
||||
{
|
||||
label: 'Submenu 1.1.1',
|
||||
icon: 'pi pi-fw pi-align-left'
|
||||
},
|
||||
{
|
||||
label: 'Submenu 1.1.2',
|
||||
icon: 'pi pi-fw pi-align-left'
|
||||
},
|
||||
{
|
||||
label: 'Submenu 1.1.3',
|
||||
icon: 'pi pi-fw pi-align-left'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Submenu 1.2',
|
||||
icon: 'pi pi-fw pi-align-left',
|
||||
path: '/submenu_1_2',
|
||||
items: [
|
||||
{
|
||||
label: 'Submenu 1.2.1',
|
||||
icon: 'pi pi-fw pi-align-left'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Submenu 2',
|
||||
icon: 'pi pi-fw pi-align-left',
|
||||
path: '/submenu_2',
|
||||
items: [
|
||||
{
|
||||
label: 'Submenu 2.1',
|
||||
icon: 'pi pi-fw pi-align-left',
|
||||
path: '/submenu_2_1',
|
||||
items: [
|
||||
{
|
||||
label: 'Submenu 2.1.1',
|
||||
icon: 'pi pi-fw pi-align-left'
|
||||
},
|
||||
{
|
||||
label: 'Submenu 2.1.2',
|
||||
icon: 'pi pi-fw pi-align-left'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Submenu 2.2',
|
||||
icon: 'pi pi-fw pi-align-left',
|
||||
path: '/submenu_2_2',
|
||||
items: [
|
||||
{
|
||||
label: 'Submenu 2.2.1',
|
||||
icon: 'pi pi-fw pi-align-left'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Get Started',
|
||||
path: '/start',
|
||||
items: [
|
||||
{
|
||||
label: 'Documentation',
|
||||
icon: 'pi pi-fw pi-book',
|
||||
to: '/start/documentation'
|
||||
},
|
||||
{
|
||||
label: 'View Source',
|
||||
icon: 'pi pi-fw pi-github',
|
||||
url: 'https://github.com/primefaces/sakai-vue',
|
||||
target: '_blank'
|
||||
}
|
||||
]
|
||||
import AppMenuItem from './AppMenuItem.vue'
|
||||
import AppMenuFooterPanel from './AppMenuFooterPanel.vue'
|
||||
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
|
||||
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import IconField from 'primevue/iconfield'
|
||||
import InputIcon from 'primevue/inputicon'
|
||||
import InputText from 'primevue/inputtext'
|
||||
|
||||
import { sessionRole, sessionIsSaasAdmin } from '@/app/session'
|
||||
import { getMenuByRole } from '@/navigation'
|
||||
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { layoutState } = useLayout()
|
||||
|
||||
const tenantStore = useTenantStore()
|
||||
const entitlementsStore = useEntitlementsStore()
|
||||
|
||||
const model = computed(() => {
|
||||
const base = getMenuByRole(sessionRole.value, { isSaasAdmin: sessionIsSaasAdmin.value }) || []
|
||||
|
||||
const normalize = (s) => String(s || '').toLowerCase()
|
||||
const priorityOrder = (group) => {
|
||||
const label = normalize(group?.label)
|
||||
if (label.includes('saas')) return 0
|
||||
if (label.includes('pacientes')) return 1
|
||||
return 99
|
||||
}
|
||||
|
||||
return [...base].sort((a, b) => priorityOrder(a) - priorityOrder(b))
|
||||
})
|
||||
|
||||
const tenantId = computed(() => tenantStore.activeTenantId || null)
|
||||
|
||||
watch(
|
||||
tenantId,
|
||||
async (id) => {
|
||||
entitlementsStore.invalidate()
|
||||
if (id) await entitlementsStore.loadForTenant(id, { force: true })
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => sessionRole.value,
|
||||
async () => {
|
||||
if (!tenantId.value) return
|
||||
entitlementsStore.invalidate()
|
||||
await entitlementsStore.loadForTenant(tenantId.value, { force: true })
|
||||
}
|
||||
)
|
||||
|
||||
// ✅ rota -> activePath (NÃO fecha menu em nenhum cenário)
|
||||
watch(
|
||||
() => route.path,
|
||||
(p) => { layoutState.activePath = p },
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// ==============================
|
||||
// 🔎 Busca no menu (flatten + resultados)
|
||||
// ==============================
|
||||
const query = ref('')
|
||||
const showResults = ref(false)
|
||||
const activeIndex = ref(-1)
|
||||
|
||||
// ✅ garante Ctrl/Cmd+K mesmo sem recentes
|
||||
const forcedOpen = ref(false)
|
||||
|
||||
// ref do InputText (pra Ctrl/Cmd + K)
|
||||
const searchEl = ref(null)
|
||||
|
||||
// wrapper pra click-outside
|
||||
const searchWrapEl = ref(null)
|
||||
|
||||
// Recentes
|
||||
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 clearRecent () {
|
||||
recent.value = []
|
||||
try { localStorage.removeItem(RECENT_KEY) } catch {}
|
||||
}
|
||||
loadRecent()
|
||||
|
||||
watch(query, (v) => {
|
||||
const hasText = !!v?.trim()
|
||||
|
||||
// digitou: abre e sai do modo "forced"
|
||||
if (hasText) {
|
||||
forcedOpen.value = false
|
||||
showResults.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// vazio: mantém aberto apenas se foi "forçado" (Ctrl/Cmd+K ou foco)
|
||||
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 flattenMenu (items, trail = []) {
|
||||
const out = []
|
||||
for (const it of (items || [])) {
|
||||
if (it?.visible === false) 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.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 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
|
||||
})
|
||||
|
||||
// ===== highlight =====
|
||||
function escapeHtml (s) {
|
||||
return String(s || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
// ===== teclado =====
|
||||
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
|
||||
}
|
||||
|
||||
// ===== Ctrl/Cmd + K =====
|
||||
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')
|
||||
|
||||
input?.focus?.()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function onGlobalKeydown (e) {
|
||||
if (isTypingTarget(document.activeElement)) return
|
||||
|
||||
const isK = e.key?.toLowerCase() === 'k'
|
||||
const withCmdOrCtrl = e.ctrlKey || e.metaKey
|
||||
|
||||
if (withCmdOrCtrl && isK) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
focusSearch()
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Recentes: aplicar query + abrir + focar (sem depender de watch timing)
|
||||
function applyRecent (q) {
|
||||
query.value = q
|
||||
forcedOpen.value = true
|
||||
showResults.value = true
|
||||
activeIndex.value = 0
|
||||
|
||||
nextTick(() => {
|
||||
// garante foco e teclado funcionando
|
||||
focusSearch()
|
||||
})
|
||||
}
|
||||
|
||||
// click outside para fechar painel
|
||||
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)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onGlobalKeydown, true)
|
||||
document.removeEventListener('mousedown', onDocMouseDown)
|
||||
})
|
||||
|
||||
async function goTo (r) {
|
||||
saveRecent(query.value)
|
||||
|
||||
query.value = ''
|
||||
showResults.value = false
|
||||
activeIndex.value = -1
|
||||
forcedOpen.value = false
|
||||
|
||||
await router.push(r.to)
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// Quick create
|
||||
// ==============================
|
||||
const quickDialog = ref(false)
|
||||
function onQuickCreate () { quickDialog.value = true }
|
||||
function onQuickCreated () { quickDialog.value = false }
|
||||
|
||||
// controle de “recentes”: mostrar ao focar (mesmo sem recentes, para exibir dicas)
|
||||
function onSearchFocus () {
|
||||
if (!query.value?.trim()) {
|
||||
forcedOpen.value = true
|
||||
showResults.value = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="layout-menu">
|
||||
<template v-for="(item, i) in model" :key="item">
|
||||
<app-menu-item v-if="!item.separator" :item="item" :index="i"></app-menu-item>
|
||||
<li v-if="item.separator" class="menu-separator"></li>
|
||||
</template>
|
||||
</ul>
|
||||
</template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- 🔎 TOPO FIXO -->
|
||||
<div ref="searchWrapEl" class="pb-2 border-b border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<div class="relative">
|
||||
<FloatLabel variant="on" class="w-full">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText
|
||||
ref="searchEl"
|
||||
id="menu_search"
|
||||
v-model="query"
|
||||
class="w-full pr-10"
|
||||
variant="filled"
|
||||
@focus="onSearchFocus"
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
</IconField>
|
||||
<label for="menu_search">Buscar no menu</label>
|
||||
</FloatLabel>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
<!-- ✅ botão limpar busca -->
|
||||
<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>
|
||||
|
||||
<!-- Recentes (quando query vazio) -->
|
||||
<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>
|
||||
|
||||
<!-- Resultados -->
|
||||
<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="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 || r.feature"
|
||||
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>
|
||||
|
||||
<!-- ✅ instruções embaixo quando houver recentes/resultados/uso -->
|
||||
<div
|
||||
v-if="showResults && (recent.length || results.length || query.trim())"
|
||||
class="mt-2 px-3 text-xs opacity-70 flex flex-wrap gap-x-3 gap-y-1"
|
||||
>
|
||||
<span><b>Ctrl+K</b>/<b>Cmd+K</b> focar</span>
|
||||
<span><b>↑↓</b> navegar</span>
|
||||
<span><b>Enter</b> abrir</span>
|
||||
<span><b>Esc</b> fechar</span>
|
||||
</div>
|
||||
|
||||
<!-- fallback quando não tem nada -->
|
||||
<div
|
||||
v-else-if="showResults && !query.trim() && !recent.length"
|
||||
class="mt-2 px-3 py-2 text-xs opacity-60"
|
||||
>
|
||||
Dica: pressione <b>Ctrl + K</b> (ou <b>Cmd + K</b>) para buscar.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ✅ SOMENTE O MENU ROLA -->
|
||||
<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>
|
||||
|
||||
<!-- rodapé fixo -->
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { sessionUser, sessionRole } from '@/app/session'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
const router = useRouter()
|
||||
const pop = ref(null)
|
||||
|
||||
function isAdminRole (r) {
|
||||
return r === 'admin' || r === 'tenant_admin'
|
||||
}
|
||||
|
||||
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 sublabel = computed(() => {
|
||||
const r = sessionRole.value
|
||||
if (!r) return 'Sessão'
|
||||
if (isAdminRole(r)) return 'Administrador'
|
||||
if (r === 'therapist') return 'Terapeuta'
|
||||
if (r === 'patient') return 'Paciente'
|
||||
return r
|
||||
})
|
||||
|
||||
function toggle (e) {
|
||||
pop.value?.toggle(e)
|
||||
}
|
||||
|
||||
function close () {
|
||||
try {
|
||||
pop.value?.hide()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function goMyProfile () {
|
||||
close()
|
||||
|
||||
// navegação segura por name
|
||||
safePush(
|
||||
{ name: 'MeuPerfil' },
|
||||
'/me/perfil'
|
||||
)
|
||||
}
|
||||
|
||||
function goSettings () {
|
||||
close()
|
||||
|
||||
const r = sessionRole.value
|
||||
|
||||
if (isAdminRole(r) || r === 'therapist') {
|
||||
// rota por name (como você já usa)
|
||||
router.push({ name: 'ConfiguracoesAgenda' })
|
||||
return
|
||||
}
|
||||
|
||||
if (r === 'patient') {
|
||||
router.push('/patient/conta')
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
async function safePush (target, fallback) {
|
||||
try {
|
||||
await router.push(target)
|
||||
} catch (e) {
|
||||
// fallback quando o "name" não existe no router
|
||||
if (fallback) {
|
||||
try {
|
||||
await router.push(fallback)
|
||||
} catch {
|
||||
await router.push('/')
|
||||
}
|
||||
} else {
|
||||
await router.push('/')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function goSecurity () {
|
||||
close()
|
||||
|
||||
// ✅ 1) tenta por NAME (recomendado)
|
||||
// ✅ 2) fallback: caminhos mais prováveis do teu projeto
|
||||
// Ajuste/defina a rota no router como name: 'AdminSecurity' para ficar perfeito
|
||||
safePush(
|
||||
{ name: 'AdminSecurity' },
|
||||
'/admin/settings/security'
|
||||
)
|
||||
}
|
||||
|
||||
async function signOut () {
|
||||
close()
|
||||
try {
|
||||
await supabase.auth.signOut()
|
||||
} catch {
|
||||
// se falhar, ainda assim manda pro login
|
||||
} finally {
|
||||
router.push('/auth/login')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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"
|
||||
@click="toggle"
|
||||
>
|
||||
<!-- avatar -->
|
||||
<img
|
||||
v-if="sessionUser.value?.user_metadata?.avatar_url"
|
||||
:src="sessionUser.value.user_metadata.avatar_url"
|
||||
class="h-9 w-9 rounded-xl object-cover border border-[var(--surface-border)]"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<!-- labels -->
|
||||
<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-70" />
|
||||
</button>
|
||||
|
||||
<Popover ref="pop" appendTo="body">
|
||||
<div class="min-w-[220px] p-1">
|
||||
<Button
|
||||
label="Configurações"
|
||||
icon="pi pi-cog"
|
||||
text
|
||||
class="w-full justify-start"
|
||||
@click="goSettings"
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Segurança"
|
||||
icon="pi pi-shield"
|
||||
text
|
||||
class="w-full justify-start"
|
||||
@click="goSecurity"
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Meu Perfil"
|
||||
icon="pi pi-user"
|
||||
text
|
||||
class="w-full justify-start"
|
||||
@click="goMyProfile"
|
||||
/>
|
||||
|
||||
<div class="my-1 border-t border-[var(--surface-border)]" />
|
||||
|
||||
<Button
|
||||
label="Sair"
|
||||
icon="pi pi-sign-out"
|
||||
severity="danger"
|
||||
text
|
||||
class="w-full justify-start"
|
||||
@click="signOut"
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
+207
-60
@@ -1,78 +1,225 @@
|
||||
<script setup>
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import { computed } from 'vue';
|
||||
import { useLayout } from '@/layout/composables/layout'
|
||||
import { computed, ref, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const { layoutState, isDesktop } = useLayout();
|
||||
import Popover from 'primevue/popover'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
|
||||
const { layoutState, isDesktop } = useLayout()
|
||||
const router = useRouter()
|
||||
const pop = ref(null)
|
||||
|
||||
const tenantStore = useTenantStore()
|
||||
const entitlementsStore = useEntitlementsStore()
|
||||
|
||||
const emit = defineEmits(['quick-create'])
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
root: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
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
|
||||
)
|
||||
|
||||
// ==============================
|
||||
// Active logic: mantém submenu aberto se algum descendente estiver ativo
|
||||
// ==============================
|
||||
function isSameRoute (current, target) {
|
||||
if (!current || !target) return false
|
||||
return current === target || current.startsWith(target + '/')
|
||||
}
|
||||
|
||||
function hasActiveDescendant (node, currentPath) {
|
||||
const children = node?.items || []
|
||||
for (const child of children) {
|
||||
if (child?.to && isSameRoute(currentPath, child.to)) return true
|
||||
if (child?.items?.length && hasActiveDescendant(child, currentPath)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const isActive = computed(() => {
|
||||
return props.item.path ? layoutState.activePath?.startsWith(fullPath.value) : layoutState.activePath === props.item.to;
|
||||
});
|
||||
const current = layoutState.activePath || ''
|
||||
const item = props.item
|
||||
|
||||
const itemClick = (event, item) => {
|
||||
if (item.disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
// grupo com submenu: active se qualquer descendente estiver ativo
|
||||
if (item?.items?.length) {
|
||||
if (hasActiveDescendant(item, current)) return true
|
||||
|
||||
if (item.command) {
|
||||
item.command({ originalEvent: event, item: item });
|
||||
}
|
||||
// fallback pelo "path" (útil para rotas internas tipo /saas/plans/123)
|
||||
return item.path ? current.startsWith(fullPath.value || '') : false
|
||||
}
|
||||
|
||||
if (item.items) {
|
||||
if (isActive.value) {
|
||||
layoutState.activePath = layoutState.activePath.replace(item.path, '');
|
||||
} else {
|
||||
layoutState.activePath = fullPath.value;
|
||||
layoutState.menuHoverActive = true;
|
||||
}
|
||||
// folha: active se rota igual ao to
|
||||
return item?.to ? isSameRoute(current, item.to) : false
|
||||
})
|
||||
|
||||
// ==============================
|
||||
// Feature lock + label
|
||||
// ==============================
|
||||
const ownerId = computed(() => tenantStore.activeTenantId || null)
|
||||
|
||||
const isLocked = computed(() => {
|
||||
const feature = props.item?.feature
|
||||
return !!(props.item?.proBadge && feature && ownerId.value && !entitlementsStore.has(feature))
|
||||
})
|
||||
|
||||
const itemDisabled = computed(() => !!props.item?.disabled)
|
||||
const isBlocked = computed(() => itemDisabled.value || isLocked.value)
|
||||
|
||||
const labelText = computed(() => {
|
||||
const base = props.item?.label || ''
|
||||
return props.item?.proBadge && isLocked.value ? `${base} (PRO)` : base
|
||||
})
|
||||
|
||||
const itemClick = async (event, item) => {
|
||||
// 🔒 locked -> CTA upgrade
|
||||
if (props.item?.proBadge && isLocked.value) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
layoutState.overlayMenuActive = false
|
||||
layoutState.mobileMenuActive = false
|
||||
layoutState.menuHoverActive = false
|
||||
|
||||
await nextTick()
|
||||
await router.push({ name: 'upgrade', query: { feature: props.item?.feature || '' } })
|
||||
return
|
||||
}
|
||||
|
||||
// 🚫 disabled -> bloqueia
|
||||
if (itemDisabled.value) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
// commands
|
||||
if (item?.command) item.command({ originalEvent: event, item })
|
||||
|
||||
// ✅ submenu: expande/colapsa e não navega
|
||||
if (item?.items?.length) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (isActive.value) {
|
||||
layoutState.activePath = props.parentPath || ''
|
||||
} else {
|
||||
layoutState.overlayMenuActive = false;
|
||||
layoutState.mobileMenuActive = false;
|
||||
layoutState.menuHoverActive = false;
|
||||
layoutState.activePath = fullPath.value
|
||||
layoutState.menuHoverActive = true
|
||||
}
|
||||
};
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ leaf: marca ativo e NÃO fecha menu
|
||||
if (item?.to) layoutState.activePath = 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
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- POPUP + ---------- */
|
||||
|
||||
function togglePopover (event) {
|
||||
if (isBlocked.value) return
|
||||
pop.value?.toggle(event)
|
||||
}
|
||||
|
||||
function closePopover () {
|
||||
try { pop.value?.hide() } catch {}
|
||||
}
|
||||
|
||||
function abrirCadastroRapido () {
|
||||
closePopover()
|
||||
emit('quick-create', {
|
||||
entity: props.item?.quickCreateEntity || 'patient',
|
||||
mode: 'rapido'
|
||||
})
|
||||
}
|
||||
|
||||
async function irCadastroCompleto () {
|
||||
closePopover()
|
||||
|
||||
layoutState.overlayMenuActive = false
|
||||
layoutState.mobileMenuActive = false
|
||||
layoutState.menuHoverActive = false
|
||||
|
||||
await nextTick()
|
||||
router.push('/admin/pacientes/cadastro')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActive }">
|
||||
<div v-if="root && item.visible !== false" class="layout-menuitem-root-text">{{ item.label }}</div>
|
||||
<a v-if="(!item.to || item.items) && item.visible !== false" :href="item.url" @click="itemClick($event, item)" :class="item.class" :target="item.target" tabindex="0" @mouseenter="onMouseEnter">
|
||||
<i :class="item.icon" class="layout-menuitem-icon" />
|
||||
<span class="layout-menuitem-text">{{ item.label }}</span>
|
||||
<i class="pi pi-fw pi-angle-down layout-submenu-toggler" v-if="item.items" />
|
||||
</a>
|
||||
<router-link v-if="item.to && !item.items && item.visible !== false" @click="itemClick($event, item)" exactActiveClass="active-route" :class="item.class" tabindex="0" :to="item.to" @mouseenter="onMouseEnter">
|
||||
<i :class="item.icon" class="layout-menuitem-icon" />
|
||||
<span class="layout-menuitem-text">{{ item.label }}</span>
|
||||
<i class="pi pi-fw pi-angle-down layout-submenu-toggler" v-if="item.items" />
|
||||
</router-link>
|
||||
<Transition v-if="item.items && item.visible !== false" name="layout-submenu">
|
||||
<ul v-show="root ? true : isActive" class="layout-submenu">
|
||||
<app-menu-item v-for="child in item.items" :key="child.label + '_' + (child.to || child.path)" :item="child" :root="false" :parentPath="fullPath" />
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<li :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActive }">
|
||||
<div v-if="root && item.visible !== false" class="layout-menuitem-root-text">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
|
||||
<div v-if="!root && item.visible !== false" 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' : '']"
|
||||
: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 }}
|
||||
<!-- (debug) pode remover depois -->
|
||||
<small style="opacity:.6">[locked={{ isLocked }}]</small>
|
||||
</span>
|
||||
|
||||
<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>
|
||||
|
||||
<Popover v-if="item.quickCreate" ref="pop">
|
||||
<div class="flex flex-column gap-2 min-w-[180px]">
|
||||
<Button label="Cadastro rápido" icon="pi pi-bolt" text @click="abrirCadastroRapido" />
|
||||
<Button label="Cadastro completo" icon="pi pi-user-plus" text @click="irCadastroCompleto" />
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
<Transition v-if="item.items && item.visible !== false" 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>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<div class="layout-wrapper">
|
||||
<AppTopbar @toggleMenu="toggleSidebar" />
|
||||
<AppSidebar :model="menu" :visible="sidebarVisible" @hide="sidebarVisible=false" />
|
||||
<div class="layout-main-container">
|
||||
<div class="layout-main">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useSessionStore } from '@/app/store/sessionStore'
|
||||
import { getMenuByRole } from '@/navigation'
|
||||
import AppTopbar from '@/components/layout/AppTopbar.vue'
|
||||
import AppSidebar from '@/components/layout/AppSidebar.vue'
|
||||
|
||||
const sidebarVisible = ref(true)
|
||||
function toggleSidebar(){ sidebarVisible.value = !sidebarVisible.value }
|
||||
|
||||
const session = useSessionStore()
|
||||
const menu = computed(() => getMenuByRole(session.role))
|
||||
</script>
|
||||
+295
-70
@@ -1,79 +1,304 @@
|
||||
<script setup>
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import AppConfigurator from './AppConfigurator.vue';
|
||||
import { computed, ref, onMounted, provide, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const { toggleMenu, toggleDarkMode, isDarkTheme } = useLayout();
|
||||
import { useLayout } from '@/layout/composables/layout'
|
||||
import AppConfigurator from './AppConfigurator.vue'
|
||||
|
||||
import Button from 'primevue/button'
|
||||
import Toast from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence'
|
||||
|
||||
// ✅ engine central
|
||||
import { applyThemeEngine } from '@/theme/theme.options'
|
||||
|
||||
const toast = useToast()
|
||||
const entitlementsStore = useEntitlementsStore()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
const { toggleMenu, toggleDarkMode, isDarkTheme, layoutConfig, changeMenuMode } = useLayout()
|
||||
const router = useRouter()
|
||||
|
||||
/* ----------------------------
|
||||
Persistência (1 instância)
|
||||
----------------------------- */
|
||||
const { init: initUserSettings, queuePatch } = useUserSettingsPersistence()
|
||||
|
||||
provide('queueUserSettingsPatch', queuePatch)
|
||||
|
||||
/* ----------------------------
|
||||
Fonte da verdade: DOM
|
||||
----------------------------- */
|
||||
function isDarkNow() {
|
||||
return document.documentElement.classList.contains('app-dark')
|
||||
}
|
||||
|
||||
function setDarkMode(shouldBeDark) {
|
||||
const now = isDarkNow()
|
||||
if (shouldBeDark !== now) toggleDarkMode()
|
||||
}
|
||||
|
||||
async function waitForDarkFlip(before, timeoutMs = 900) {
|
||||
const start = performance.now()
|
||||
|
||||
while (performance.now() - start < timeoutMs) {
|
||||
await nextTick()
|
||||
await new Promise((r) => requestAnimationFrame(r))
|
||||
|
||||
const now = isDarkNow()
|
||||
if (now !== before) return now
|
||||
}
|
||||
return isDarkNow()
|
||||
}
|
||||
|
||||
/* ----------------------------
|
||||
Bootstrap: carrega e aplica
|
||||
----------------------------- */
|
||||
async function loadAndApplyUserSettings() {
|
||||
try {
|
||||
const { data: u, error: uErr } = await supabase.auth.getUser()
|
||||
if (uErr) throw uErr
|
||||
const uid = u?.user?.id
|
||||
if (!uid) return
|
||||
|
||||
const { data: settings, error } = await supabase
|
||||
.from('user_settings')
|
||||
.select('theme_mode, preset, primary_color, surface_color, menu_mode')
|
||||
.eq('user_id', uid)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw error
|
||||
if (!settings) {
|
||||
console.log('[Topbar][bootstrap] sem user_settings ainda')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[Topbar][bootstrap] settings=', settings)
|
||||
|
||||
// dark/light
|
||||
if (settings.theme_mode) setDarkMode(settings.theme_mode === 'dark')
|
||||
|
||||
// layoutConfig
|
||||
if (settings.preset) layoutConfig.preset = settings.preset
|
||||
if (settings.primary_color) layoutConfig.primary = settings.primary_color
|
||||
if (settings.surface_color) layoutConfig.surface = settings.surface_color
|
||||
if (settings.menu_mode) layoutConfig.menuMode = settings.menu_mode
|
||||
|
||||
// aplica tema via engine única
|
||||
applyThemeEngine(layoutConfig)
|
||||
|
||||
// aplica menu mode
|
||||
try { changeMenuMode() } catch (e) {
|
||||
console.warn('[Topbar][bootstrap] changeMenuMode falhou:', e?.message || e)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Topbar][bootstrap] erro:', e?.message || e)
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------
|
||||
Atalho topbar: Dark/Light
|
||||
----------------------------- */
|
||||
async function toggleDarkAndPersistSilently() {
|
||||
try {
|
||||
const before = isDarkNow()
|
||||
console.log('[Topbar][theme] click. before=', before ? 'dark' : 'light')
|
||||
|
||||
toggleDarkMode()
|
||||
|
||||
const after = await waitForDarkFlip(before)
|
||||
const theme_mode = after ? 'dark' : 'light'
|
||||
|
||||
console.log('[Topbar][theme] after=', theme_mode, 'isDarkTheme=', !!isDarkTheme)
|
||||
|
||||
await queuePatch({ theme_mode }, { flushNow: true })
|
||||
|
||||
console.log('[Topbar][theme] saved theme_mode=', theme_mode)
|
||||
} catch (e) {
|
||||
console.error('[Topbar][theme] falhou:', e?.message || e)
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------------
|
||||
Plano (teu código intacto)
|
||||
----------------------------- */
|
||||
const tenantId = computed(() => tenantStore.activeTenantId || null)
|
||||
const trocandoPlano = ref(false)
|
||||
|
||||
async function getPlanIdByKey(planKey) {
|
||||
const { data, error } = await supabase.from('plans').select('id, key').eq('key', planKey).single()
|
||||
if (error) throw error
|
||||
return data.id
|
||||
}
|
||||
|
||||
async function getActiveSubscriptionByTenant(tid) {
|
||||
const { data, error } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('id, tenant_id, plan_id, status, created_at, updated_at')
|
||||
.eq('tenant_id', tid)
|
||||
.eq('status', 'active')
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw error
|
||||
return data || null
|
||||
}
|
||||
|
||||
async function getPlanKeyById(planId) {
|
||||
const { data, error } = await supabase.from('plans').select('key').eq('id', planId).single()
|
||||
if (error) throw error
|
||||
return data.key
|
||||
}
|
||||
|
||||
async function alternarPlano() {
|
||||
if (trocandoPlano.value) return
|
||||
trocandoPlano.value = true
|
||||
|
||||
try {
|
||||
const tid = tenantId.value
|
||||
if (!tid) {
|
||||
toast.add({ severity: 'warn', summary: 'Sem tenant ativo', detail: 'Selecione/entre em uma clínica (tenant) antes de trocar o plano.', life: 4500 })
|
||||
return
|
||||
}
|
||||
|
||||
const sub = await getActiveSubscriptionByTenant(tid)
|
||||
if (!sub?.id) {
|
||||
toast.add({ severity: 'warn', summary: 'Sem assinatura ativa', detail: 'Esse tenant ainda não tem subscription ativa. Ative via intenção/pagamento manual.', life: 5000 })
|
||||
return
|
||||
}
|
||||
|
||||
const atualKey = await getPlanKeyById(sub.plan_id)
|
||||
const novoKey = atualKey === 'pro' ? 'free' : 'pro'
|
||||
const novoPlanId = await getPlanIdByKey(novoKey)
|
||||
|
||||
const { error: rpcError } = await supabase.rpc('change_subscription_plan', {
|
||||
p_subscription_id: sub.id,
|
||||
p_new_plan_id: novoPlanId
|
||||
})
|
||||
if (rpcError) throw rpcError
|
||||
|
||||
entitlementsStore.clear?.()
|
||||
await entitlementsStore.fetch(tid, { force: true })
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Plano alternado', detail: `${String(atualKey).toUpperCase()} → ${String(novoKey).toUpperCase()}`, life: 3000 })
|
||||
} catch (err) {
|
||||
console.error('[PLANO] Erro ao alternar:', err?.message || err)
|
||||
toast.add({ severity: 'error', summary: 'Erro ao alternar plano', detail: err?.message || 'Falha desconhecida.', life: 5000 })
|
||||
} finally {
|
||||
trocandoPlano.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await supabase.auth.signOut()
|
||||
} finally {
|
||||
router.push('/auth/login')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await initUserSettings()
|
||||
await loadAndApplyUserSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-topbar">
|
||||
<div class="layout-topbar-logo-container">
|
||||
<button class="layout-menu-button layout-topbar-action" @click="toggleMenu">
|
||||
<i class="pi pi-bars"></i>
|
||||
</button>
|
||||
<router-link to="/" class="layout-topbar-logo">
|
||||
<svg viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M17.1637 19.2467C17.1566 19.4033 17.1529 19.561 17.1529 19.7194C17.1529 25.3503 21.7203 29.915 27.3546 29.915C32.9887 29.915 37.5561 25.3503 37.5561 19.7194C37.5561 19.5572 37.5524 19.3959 37.5449 19.2355C38.5617 19.0801 39.5759 18.9013 40.5867 18.6994L40.6926 18.6782C40.7191 19.0218 40.7326 19.369 40.7326 19.7194C40.7326 27.1036 34.743 33.0896 27.3546 33.0896C19.966 33.0896 13.9765 27.1036 13.9765 19.7194C13.9765 19.374 13.9896 19.0316 14.0154 18.6927L14.0486 18.6994C15.0837 18.9062 16.1223 19.0886 17.1637 19.2467ZM33.3284 11.4538C31.6493 10.2396 29.5855 9.52381 27.3546 9.52381C25.1195 9.52381 23.0524 10.2421 21.3717 11.4603C20.0078 11.3232 18.6475 11.1387 17.2933 10.907C19.7453 8.11308 23.3438 6.34921 27.3546 6.34921C31.36 6.34921 34.9543 8.10844 37.4061 10.896C36.0521 11.1292 34.692 11.3152 33.3284 11.4538ZM43.826 18.0518C43.881 18.6003 43.9091 19.1566 43.9091 19.7194C43.9091 28.8568 36.4973 36.2642 27.3546 36.2642C18.2117 36.2642 10.8 28.8568 10.8 19.7194C10.8 19.1615 10.8276 18.61 10.8816 18.0663L7.75383 17.4411C7.66775 18.1886 7.62354 18.9488 7.62354 19.7194C7.62354 30.6102 16.4574 39.4388 27.3546 39.4388C38.2517 39.4388 47.0855 30.6102 47.0855 19.7194C47.0855 18.9439 47.0407 18.1789 46.9536 17.4267L43.826 18.0518ZM44.2613 9.54743L40.9084 10.2176C37.9134 5.95821 32.9593 3.1746 27.3546 3.1746C21.7442 3.1746 16.7856 5.96385 13.7915 10.2305L10.4399 9.56057C13.892 3.83178 20.1756 0 27.3546 0C34.5281 0 40.8075 3.82591 44.2613 9.54743Z"
|
||||
fill="var(--primary-color)"
|
||||
/>
|
||||
<mask id="mask0_1413_1551" style="mask-type: alpha" maskUnits="userSpaceOnUse" x="0" y="8" width="54" height="11">
|
||||
<path d="M27 18.3652C10.5114 19.1944 0 8.88892 0 8.88892C0 8.88892 16.5176 14.5866 27 14.5866C37.4824 14.5866 54 8.88892 54 8.88892C54 8.88892 43.4886 17.5361 27 18.3652Z" fill="var(--primary-color)" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_1413_1551)">
|
||||
<path
|
||||
d="M-4.673e-05 8.88887L3.73084 -1.91434L-8.00806 17.0473L-4.673e-05 8.88887ZM27 18.3652L26.4253 6.95109L27 18.3652ZM54 8.88887L61.2673 17.7127L50.2691 -1.91434L54 8.88887ZM-4.673e-05 8.88887C-8.00806 17.0473 -8.00469 17.0505 -8.00132 17.0538C-8.00018 17.055 -7.99675 17.0583 -7.9944 17.0607C-7.98963 17.0653 -7.98474 17.0701 -7.97966 17.075C-7.96949 17.0849 -7.95863 17.0955 -7.94707 17.1066C-7.92401 17.129 -7.89809 17.1539 -7.86944 17.1812C-7.8122 17.236 -7.74377 17.3005 -7.66436 17.3743C-7.50567 17.5218 -7.30269 17.7063 -7.05645 17.9221C-6.56467 18.3532 -5.89662 18.9125 -5.06089 19.5534C-3.39603 20.83 -1.02575 22.4605 1.98012 24.0457C7.97874 27.2091 16.7723 30.3226 27.5746 29.7793L26.4253 6.95109C20.7391 7.23699 16.0326 5.61231 12.6534 3.83024C10.9703 2.94267 9.68222 2.04866 8.86091 1.41888C8.45356 1.10653 8.17155 0.867278 8.0241 0.738027C7.95072 0.673671 7.91178 0.637576 7.90841 0.634492C7.90682 0.63298 7.91419 0.639805 7.93071 0.65557C7.93897 0.663455 7.94952 0.673589 7.96235 0.686039C7.96883 0.692262 7.97582 0.699075 7.98338 0.706471C7.98719 0.710167 7.99113 0.714014 7.99526 0.718014C7.99729 0.720008 8.00047 0.723119 8.00148 0.724116C8.00466 0.727265 8.00796 0.730446 -4.673e-05 8.88887ZM27.5746 29.7793C37.6904 29.2706 45.9416 26.3684 51.6602 23.6054C54.5296 22.2191 56.8064 20.8465 58.4186 19.7784C59.2265 19.2431 59.873 18.7805 60.3494 18.4257C60.5878 18.2482 60.7841 18.0971 60.9374 17.977C61.014 17.9169 61.0799 17.8645 61.1349 17.8203C61.1624 17.7981 61.1872 17.7781 61.2093 17.7602C61.2203 17.7512 61.2307 17.7427 61.2403 17.7348C61.2452 17.7308 61.2499 17.727 61.2544 17.7233C61.2566 17.7215 61.2598 17.7188 61.261 17.7179C61.2642 17.7153 61.2673 17.7127 54 8.88887C46.7326 0.0650536 46.7357 0.0625219 46.7387 0.0600241C46.7397 0.0592345 46.7427 0.0567658 46.7446 0.0551857C46.7485 0.0520238 46.7521 0.0489887 46.7557 0.0460799C46.7628 0.0402623 46.7694 0.0349487 46.7753 0.0301318C46.7871 0.0204986 46.7966 0.0128495 46.8037 0.00712562C46.818 -0.00431848 46.8228 -0.00808311 46.8184 -0.00463784C46.8096 0.00228345 46.764 0.0378652 46.6828 0.0983779C46.5199 0.219675 46.2165 0.439161 45.7812 0.727519C44.9072 1.30663 43.5257 2.14765 41.7061 3.02677C38.0469 4.79468 32.7981 6.63058 26.4253 6.95109L27.5746 29.7793ZM54 8.88887C50.2691 -1.91433 50.27 -1.91467 50.271 -1.91498C50.2712 -1.91506 50.272 -1.91535 50.2724 -1.9155C50.2733 -1.91581 50.274 -1.91602 50.2743 -1.91616C50.2752 -1.91643 50.275 -1.91636 50.2738 -1.91595C50.2714 -1.91515 50.2652 -1.91302 50.2552 -1.9096C50.2351 -1.90276 50.1999 -1.89078 50.1503 -1.874C50.0509 -1.84043 49.8938 -1.78773 49.6844 -1.71863C49.2652 -1.58031 48.6387 -1.377 47.8481 -1.13035C46.2609 -0.635237 44.0427 0.0249875 41.5325 0.6823C36.215 2.07471 30.6736 3.15796 27 3.15796V26.0151C33.8087 26.0151 41.7672 24.2495 47.3292 22.7931C50.2586 22.026 52.825 21.2618 54.6625 20.6886C55.5842 20.4011 56.33 20.1593 56.8551 19.986C57.1178 19.8993 57.3258 19.8296 57.4735 19.7797C57.5474 19.7548 57.6062 19.7348 57.6493 19.72C57.6709 19.7127 57.6885 19.7066 57.7021 19.7019C57.7089 19.6996 57.7147 19.6976 57.7195 19.696C57.7219 19.6952 57.7241 19.6944 57.726 19.6938C57.7269 19.6934 57.7281 19.693 57.7286 19.6929C57.7298 19.6924 57.7309 19.692 54 8.88887ZM27 3.15796C23.3263 3.15796 17.7849 2.07471 12.4674 0.6823C9.95717 0.0249875 7.73904 -0.635237 6.15184 -1.13035C5.36118 -1.377 4.73467 -1.58031 4.3155 -1.71863C4.10609 -1.78773 3.94899 -1.84043 3.84961 -1.874C3.79994 -1.89078 3.76474 -1.90276 3.74471 -1.9096C3.73469 -1.91302 3.72848 -1.91515 3.72613 -1.91595C3.72496 -1.91636 3.72476 -1.91643 3.72554 -1.91616C3.72593 -1.91602 3.72657 -1.91581 3.72745 -1.9155C3.72789 -1.91535 3.72874 -1.91506 3.72896 -1.91498C3.72987 -1.91467 3.73084 -1.91433 -4.673e-05 8.88887C-3.73093 19.692 -3.72983 19.6924 -3.72868 19.6929C-3.72821 19.693 -3.72698 19.6934 -3.72603 19.6938C-3.72415 19.6944 -3.72201 19.6952 -3.71961 19.696C-3.71482 19.6976 -3.70901 19.6996 -3.7022 19.7019C-3.68858 19.7066 -3.67095 19.7127 -3.6494 19.72C-3.60629 19.7348 -3.54745 19.7548 -3.47359 19.7797C-3.32589 19.8296 -3.11788 19.8993 -2.85516 19.986C-2.33008 20.1593 -1.58425 20.4011 -0.662589 20.6886C1.17485 21.2618 3.74125 22.026 6.67073 22.7931C12.2327 24.2495 20.1913 26.0151 27 26.0151V3.15796Z"
|
||||
fill="var(--primary-color)"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<Toast />
|
||||
|
||||
<span>SAKAI</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="layout-topbar">
|
||||
<div class="layout-topbar-logo-container">
|
||||
<button class="layout-menu-button layout-topbar-action" @click="toggleMenu">
|
||||
<i class="pi pi-bars"></i>
|
||||
</button>
|
||||
|
||||
<div class="layout-topbar-actions">
|
||||
<div class="layout-config-menu">
|
||||
<button type="button" class="layout-topbar-action" @click="toggleDarkMode">
|
||||
<i :class="['pi', { 'pi-moon': isDarkTheme, 'pi-sun': !isDarkTheme }]"></i>
|
||||
</button>
|
||||
<div class="relative">
|
||||
<button
|
||||
v-styleclass="{ selector: '@next', enterFromClass: 'hidden', enterActiveClass: 'p-anchored-overlay-enter-active', leaveToClass: 'hidden', leaveActiveClass: 'p-anchored-overlay-leave-active', hideOnOutsideClick: true }"
|
||||
type="button"
|
||||
class="layout-topbar-action layout-topbar-action-highlight"
|
||||
>
|
||||
<i class="pi pi-palette"></i>
|
||||
</button>
|
||||
<AppConfigurator />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="layout-topbar-menu-button layout-topbar-action"
|
||||
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-ellipsis-v"></i>
|
||||
</button>
|
||||
|
||||
<div class="layout-topbar-menu hidden lg:block">
|
||||
<div class="layout-topbar-menu-content">
|
||||
<button type="button" class="layout-topbar-action">
|
||||
<i class="pi pi-calendar"></i>
|
||||
<span>Calendar</span>
|
||||
</button>
|
||||
<button type="button" class="layout-topbar-action">
|
||||
<i class="pi pi-inbox"></i>
|
||||
<span>Messages</span>
|
||||
</button>
|
||||
<button type="button" class="layout-topbar-action">
|
||||
<i class="pi pi-user"></i>
|
||||
<span>Profile</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<router-link to="/" class="layout-topbar-logo">
|
||||
<svg viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- ... SVG gigante ... -->
|
||||
</svg>
|
||||
<span>SAKAI</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="layout-topbar-actions">
|
||||
<div class="layout-config-menu">
|
||||
<button type="button" class="layout-topbar-action" @click="toggleDarkAndPersistSilently">
|
||||
<i :class="['pi', { 'pi-moon': isDarkTheme, 'pi-sun': !isDarkTheme }]"></i>
|
||||
</button>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
v-styleclass="{
|
||||
selector: '@next',
|
||||
enterFromClass: 'hidden',
|
||||
enterActiveClass: 'p-anchored-overlay-enter-active',
|
||||
leaveToClass: 'hidden',
|
||||
leaveActiveClass: 'p-anchored-overlay-leave-active',
|
||||
hideOnOutsideClick: true
|
||||
}"
|
||||
type="button"
|
||||
class="layout-topbar-action layout-topbar-action-highlight"
|
||||
>
|
||||
<i class="pi pi-palette"></i>
|
||||
</button>
|
||||
|
||||
<AppConfigurator />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="layout-topbar-menu-button layout-topbar-action"
|
||||
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-ellipsis-v"></i>
|
||||
</button>
|
||||
|
||||
<div class="layout-topbar-menu hidden lg:block">
|
||||
<div class="layout-topbar-menu-content">
|
||||
<Button
|
||||
label="Plano"
|
||||
icon="pi pi-sync"
|
||||
severity="contrast"
|
||||
outlined
|
||||
:loading="trocandoPlano"
|
||||
:disabled="trocandoPlano"
|
||||
@click="alternarPlano"
|
||||
/>
|
||||
|
||||
<button type="button" class="layout-topbar-action">
|
||||
<i class="pi pi-calendar"></i>
|
||||
<span>Calendar</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="layout-topbar-action">
|
||||
<i class="pi pi-inbox"></i>
|
||||
<span>Messages</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="layout-topbar-action">
|
||||
<i class="pi pi-user"></i>
|
||||
<span>Profile</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="layout-topbar-action" @click="logout">
|
||||
<i class="pi pi-sign-out"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<!-- src/layout/ConfiguracoesPage.vue -->
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import Card from 'primevue/card'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
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']
|
||||
},
|
||||
|
||||
// Ative quando criar as rotas/páginas
|
||||
// {
|
||||
// key: 'clinica',
|
||||
// label: 'Clínica',
|
||||
// desc: 'Padrões clínicos, status e preferências de atendimento.',
|
||||
// icon: 'pi pi-heart',
|
||||
// to: '/configuracoes/clinica',
|
||||
// tags: ['Status', 'Modelos', 'Preferências']
|
||||
// },
|
||||
// {
|
||||
// key: 'intake',
|
||||
// label: 'Cadastros & Intake',
|
||||
// desc: 'Link externo, campos do formulário e mensagens padrão.',
|
||||
// icon: 'pi pi-file-edit',
|
||||
// to: '/configuracoes/intake',
|
||||
// tags: ['Formulário', 'Campos', 'Textos']
|
||||
// },
|
||||
// {
|
||||
// key: 'conta',
|
||||
// label: 'Conta',
|
||||
// desc: 'Perfil, segurança e preferências da conta.',
|
||||
// icon: 'pi pi-user',
|
||||
// to: '/configuracoes/conta',
|
||||
// tags: ['Perfil', 'Segurança', 'Preferências']
|
||||
// }
|
||||
]
|
||||
|
||||
const activeTo = computed(() => {
|
||||
const p = route.path || ''
|
||||
const hit = secoes.find(s => p.startsWith(s.to))
|
||||
return hit?.to || '/configuracoes/agenda'
|
||||
})
|
||||
|
||||
function ir(to) {
|
||||
if (!to) return
|
||||
if (route.path !== to) router.push(to)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<!-- HEADER CONCEITUAL -->
|
||||
<div class="mb-4 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<div class="relative p-5">
|
||||
<!-- blobs sutis -->
|
||||
<div class="pointer-events-none absolute inset-0 opacity-80">
|
||||
<div class="absolute -top-16 -right-16 h-52 w-52 rounded-full bg-emerald-400/10 blur-3xl" />
|
||||
<div class="absolute top-10 -left-24 h-60 w-60 rounded-full bg-indigo-400/10 blur-3xl" />
|
||||
<div class="absolute bottom-0 right-24 h-44 w-44 rounded-full bg-fuchsia-400/10 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-900 text-2xl font-semibold leading-none">Configurações</div>
|
||||
<div class="text-600 mt-2 max-w-2xl">
|
||||
Defina como sua clínica funciona: agenda, cadastros e preferências. Tudo no mesmo lugar — sem espalhar opções pelo sistema.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
label="Voltar"
|
||||
icon="pi pi-arrow-left"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="hidden md:inline-flex"
|
||||
@click="router.back()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<!-- SIDEBAR (seções) -->
|
||||
<div class="col-span-12 lg:col-span-4 xl:col-span-3">
|
||||
<Card class="h-full">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-cog" />
|
||||
<span>Seções</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
v-for="s in secoes"
|
||||
:key="s.key"
|
||||
type="button"
|
||||
class="w-full text-left p-3 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] hover:bg-[var(--surface-hover)] transition flex items-start justify-between gap-3"
|
||||
:class="activeTo === s.to ? 'ring-1 ring-primary/40 border-primary/40' : ''"
|
||||
@click="ir(s.to)"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
<div class="mt-1">
|
||||
<i :class="[s.icon, 'text-lg']" style="opacity:.85" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-900 font-medium leading-none">{{ s.label }}</div>
|
||||
<div class="text-600 text-sm mt-2 leading-snug">{{ s.desc }}</div>
|
||||
|
||||
<div v-if="s.tags?.length" class="mt-3 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="t in s.tags"
|
||||
:key="t"
|
||||
class="text-xs px-2 py-1 rounded-full border border-[var(--surface-border)] text-600"
|
||||
>
|
||||
{{ t }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<i class="pi pi-angle-right mt-1" style="opacity:.55" />
|
||||
</button>
|
||||
|
||||
<Divider class="my-2" />
|
||||
|
||||
<Button
|
||||
label="Voltar"
|
||||
icon="pi pi-arrow-left"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="w-full md:hidden"
|
||||
@click="router.back()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Card pequeno “atalhos” opcional -->
|
||||
<div class="mt-4 hidden lg:block">
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="text-900 font-medium">Dica</div>
|
||||
<div class="text-600 text-sm mt-2 leading-relaxed">
|
||||
Comece pela <b>Agenda</b>. É ela que dá “tempo” ao prontuário: sessão marcada → sessão realizada → evolução.
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CONTEÚDO (seção selecionada) -->
|
||||
<div class="col-span-12 lg:col-span-8 xl:col-span-9">
|
||||
<!-- Aqui entra /configuracoes/agenda etc -->
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<AppShellLayout />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppShellLayout from './AppShellLayout.vue'
|
||||
</script>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<AppShellLayout />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppShellLayout from './AppShellLayout.vue'
|
||||
</script>
|
||||
@@ -1,86 +1,96 @@
|
||||
import { computed, reactive } from 'vue';
|
||||
import { computed, reactive } from 'vue'
|
||||
|
||||
const layoutConfig = reactive({
|
||||
preset: 'Aura',
|
||||
primary: 'emerald',
|
||||
surface: null,
|
||||
darkTheme: false,
|
||||
menuMode: 'static'
|
||||
});
|
||||
preset: 'Aura',
|
||||
primary: 'emerald',
|
||||
surface: null,
|
||||
darkTheme: false,
|
||||
menuMode: 'static'
|
||||
})
|
||||
|
||||
const layoutState = reactive({
|
||||
staticMenuInactive: false,
|
||||
overlayMenuActive: false,
|
||||
profileSidebarVisible: false,
|
||||
configSidebarVisible: false,
|
||||
sidebarExpanded: false,
|
||||
menuHoverActive: false,
|
||||
activeMenuItem: null,
|
||||
activePath: null
|
||||
});
|
||||
staticMenuInactive: false,
|
||||
overlayMenuActive: false,
|
||||
mobileMenuActive: false, // ✅ ADICIONA (estava faltando)
|
||||
profileSidebarVisible: false,
|
||||
configSidebarVisible: false,
|
||||
sidebarExpanded: false,
|
||||
menuHoverActive: false,
|
||||
activeMenuItem: null,
|
||||
activePath: null
|
||||
})
|
||||
|
||||
export function useLayout() {
|
||||
const toggleDarkMode = () => {
|
||||
if (!document.startViewTransition) {
|
||||
executeDarkModeToggle();
|
||||
export function useLayout () {
|
||||
const toggleDarkMode = () => {
|
||||
if (!document.startViewTransition) {
|
||||
executeDarkModeToggle()
|
||||
return
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
document.startViewTransition(() => executeDarkModeToggle(event))
|
||||
}
|
||||
|
||||
document.startViewTransition(() => executeDarkModeToggle(event));
|
||||
};
|
||||
const executeDarkModeToggle = () => {
|
||||
layoutConfig.darkTheme = !layoutConfig.darkTheme
|
||||
document.documentElement.classList.toggle('app-dark')
|
||||
}
|
||||
|
||||
const executeDarkModeToggle = () => {
|
||||
layoutConfig.darkTheme = !layoutConfig.darkTheme;
|
||||
document.documentElement.classList.toggle('app-dark');
|
||||
};
|
||||
const isDesktop = () => window.innerWidth > 991
|
||||
|
||||
const toggleMenu = () => {
|
||||
if (isDesktop()) {
|
||||
if (layoutConfig.menuMode === 'static') {
|
||||
layoutState.staticMenuInactive = !layoutState.staticMenuInactive;
|
||||
}
|
||||
const toggleMenu = () => {
|
||||
if (isDesktop()) {
|
||||
if (layoutConfig.menuMode === 'static') {
|
||||
layoutState.staticMenuInactive = !layoutState.staticMenuInactive
|
||||
}
|
||||
|
||||
if (layoutConfig.menuMode === 'overlay') {
|
||||
layoutState.overlayMenuActive = !layoutState.overlayMenuActive;
|
||||
}
|
||||
} else {
|
||||
layoutState.mobileMenuActive = !layoutState.mobileMenuActive;
|
||||
}
|
||||
};
|
||||
if (layoutConfig.menuMode === 'overlay') {
|
||||
layoutState.overlayMenuActive = !layoutState.overlayMenuActive
|
||||
}
|
||||
} else {
|
||||
layoutState.mobileMenuActive = !layoutState.mobileMenuActive
|
||||
}
|
||||
}
|
||||
|
||||
const toggleConfigSidebar = () => {
|
||||
layoutState.configSidebarVisible = !layoutState.configSidebarVisible;
|
||||
};
|
||||
const toggleConfigSidebar = () => {
|
||||
layoutState.configSidebarVisible = !layoutState.configSidebarVisible
|
||||
}
|
||||
|
||||
const hideMobileMenu = () => {
|
||||
layoutState.mobileMenuActive = false;
|
||||
};
|
||||
const hideMobileMenu = () => {
|
||||
layoutState.mobileMenuActive = false
|
||||
}
|
||||
|
||||
const changeMenuMode = (event) => {
|
||||
layoutConfig.menuMode = event.value;
|
||||
layoutState.staticMenuInactive = false;
|
||||
layoutState.mobileMenuActive = false;
|
||||
layoutState.sidebarExpanded = false;
|
||||
layoutState.menuHoverActive = false;
|
||||
layoutState.anchored = 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
|
||||
}
|
||||
}
|
||||
|
||||
const isDarkTheme = computed(() => layoutConfig.darkTheme);
|
||||
const isDesktop = () => window.innerWidth > 991;
|
||||
const changeMenuMode = (event) => {
|
||||
layoutConfig.menuMode = event.value
|
||||
layoutState.staticMenuInactive = false
|
||||
layoutState.mobileMenuActive = false
|
||||
layoutState.sidebarExpanded = false
|
||||
layoutState.menuHoverActive = false
|
||||
layoutState.anchored = false
|
||||
}
|
||||
|
||||
const hasOpenOverlay = computed(() => layoutState.overlayMenuActive);
|
||||
const isDarkTheme = computed(() => layoutConfig.darkTheme)
|
||||
const hasOpenOverlay = computed(() => layoutState.overlayMenuActive)
|
||||
|
||||
return {
|
||||
layoutConfig,
|
||||
layoutState,
|
||||
isDarkTheme,
|
||||
toggleDarkMode,
|
||||
toggleConfigSidebar,
|
||||
toggleMenu,
|
||||
hideMobileMenu,
|
||||
changeMenuMode,
|
||||
isDesktop,
|
||||
hasOpenOverlay
|
||||
};
|
||||
return {
|
||||
layoutConfig,
|
||||
layoutState,
|
||||
isDarkTheme,
|
||||
toggleDarkMode,
|
||||
toggleConfigSidebar,
|
||||
toggleMenu,
|
||||
hideMobileMenu,
|
||||
closeMenuOnNavigate, // ✅ exporta
|
||||
changeMenuMode,
|
||||
isDesktop,
|
||||
hasOpenOverlay
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user