492 lines
12 KiB
Svelte
492 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
|
import { useQuery } from 'convex-svelte';
|
|
import type { FunctionReference } from 'convex/server';
|
|
import {
|
|
ChevronDown,
|
|
ClipboardCheck,
|
|
FileText,
|
|
GitMerge,
|
|
Home,
|
|
Settings,
|
|
Tag,
|
|
Users,
|
|
Briefcase,
|
|
UserPlus,
|
|
Package
|
|
} from 'lucide-svelte';
|
|
import { resolve } from '$app/paths';
|
|
import { page } from '$app/state';
|
|
|
|
interface MenuItemPermission {
|
|
recurso: string;
|
|
acao: string;
|
|
}
|
|
|
|
interface SubMenuItem {
|
|
label: string;
|
|
link: string;
|
|
permission?: MenuItemPermission;
|
|
excludePaths?: string[];
|
|
exact?: boolean;
|
|
}
|
|
|
|
interface MenuItem {
|
|
label: string;
|
|
icon: string;
|
|
link: string;
|
|
permission?: MenuItemPermission;
|
|
submenus?: SubMenuItem[];
|
|
excludePaths?: string[];
|
|
exact?: boolean;
|
|
}
|
|
|
|
// Estrutura do menu definida no frontend
|
|
const MENU_STRUCTURE = [
|
|
{
|
|
label: 'Dashboard',
|
|
icon: 'Home',
|
|
link: '/'
|
|
},
|
|
{
|
|
label: 'Gestão de Pessoas',
|
|
icon: 'Users',
|
|
link: '/recursos-humanos',
|
|
permission: { recurso: 'gestao_pessoas', acao: 'ver' },
|
|
submenus: [
|
|
{
|
|
label: 'Funcionários',
|
|
link: '/recursos-humanos/funcionarios',
|
|
permission: { recurso: 'funcionarios', acao: 'listar' },
|
|
exact: true
|
|
},
|
|
{
|
|
label: 'Cadastro de Funcionários',
|
|
link: '/recursos-humanos/funcionarios/cadastro',
|
|
permission: { recurso: 'funcionarios', acao: 'criar' }
|
|
},
|
|
{
|
|
label: 'Exclusão de Funcionários',
|
|
link: '/recursos-humanos/funcionarios/excluir',
|
|
permission: { recurso: 'funcionarios', acao: 'excluir' }
|
|
},
|
|
{
|
|
label: 'Férias',
|
|
link: '/recursos-humanos/ferias',
|
|
permission: { recurso: 'ferias', acao: 'dashboard' }
|
|
},
|
|
{
|
|
label: 'Atestados de Licenças',
|
|
link: '/recursos-humanos/atestados-licencas',
|
|
permission: { recurso: 'atestados_licencas', acao: 'listar' }
|
|
},
|
|
{
|
|
label: 'Controle de Ponto',
|
|
link: '/recursos-humanos/controle-ponto',
|
|
permission: { recurso: 'ponto', acao: 'ver' },
|
|
exact: true
|
|
},
|
|
{
|
|
label: 'Banco de Horas',
|
|
link: '/recursos-humanos/controle-ponto/banco-horas',
|
|
permission: { recurso: 'banco_horas', acao: 'ver' }
|
|
},
|
|
{
|
|
label: 'Registro de Ponto',
|
|
link: '/recursos-humanos/registro-pontos',
|
|
permission: { recurso: 'ponto', acao: 'ver' }
|
|
},
|
|
{
|
|
label: 'Símbolos',
|
|
link: '/recursos-humanos/simbolos',
|
|
permission: { recurso: 'simbolos', acao: 'listar' }
|
|
}
|
|
]
|
|
},
|
|
{
|
|
label: 'Pedidos',
|
|
icon: 'ClipboardCheck',
|
|
link: '/pedidos',
|
|
permission: { recurso: 'pedidos', acao: 'listar' },
|
|
submenus: [
|
|
{
|
|
label: 'Novo Pedido',
|
|
link: '/pedidos/novo',
|
|
permission: { recurso: 'pedidos', acao: 'criar' }
|
|
},
|
|
{
|
|
label: 'Planejamentos',
|
|
link: '/pedidos/planejamento',
|
|
permission: { recurso: 'pedidos', acao: 'listar' }
|
|
},
|
|
{
|
|
label: 'Meus Pedidos',
|
|
link: '/pedidos',
|
|
permission: { recurso: 'pedidos', acao: 'listar' },
|
|
excludePaths: [
|
|
'/pedidos/aceite',
|
|
'/pedidos/minhas-analises',
|
|
'/pedidos/novo',
|
|
'/pedidos/planejamento'
|
|
]
|
|
},
|
|
{
|
|
label: 'Pedidos para Aceite',
|
|
link: '/pedidos/aceite',
|
|
permission: { recurso: 'pedidos', acao: 'aceitar' }
|
|
},
|
|
{
|
|
label: 'Minhas Análises',
|
|
link: '/pedidos/minhas-analises',
|
|
permission: { recurso: 'pedidos', acao: 'aceitar' }
|
|
}
|
|
]
|
|
},
|
|
{
|
|
label: 'Almoxarifado',
|
|
icon: 'Package',
|
|
link: '/almoxarifado',
|
|
permission: { recurso: 'almoxarifado', acao: 'listar' },
|
|
submenus: [
|
|
{
|
|
label: 'Dashboard',
|
|
link: '/almoxarifado',
|
|
permission: { recurso: 'almoxarifado', acao: 'listar' },
|
|
excludePaths: [
|
|
'/almoxarifado/materiais',
|
|
'/almoxarifado/materiais/cadastro',
|
|
'/almoxarifado/movimentacoes',
|
|
'/almoxarifado/requisicoes',
|
|
'/almoxarifado/alertas',
|
|
'/almoxarifado/relatorios'
|
|
]
|
|
},
|
|
{
|
|
label: 'Cadastrar Material',
|
|
link: '/almoxarifado/materiais/cadastro',
|
|
permission: { recurso: 'almoxarifado', acao: 'criar_material' }
|
|
},
|
|
{
|
|
label: 'Listar Materiais',
|
|
link: '/almoxarifado/materiais',
|
|
permission: { recurso: 'almoxarifado', acao: 'listar' },
|
|
excludePaths: ['/almoxarifado/materiais/cadastro']
|
|
},
|
|
{
|
|
label: 'Movimentações',
|
|
link: '/almoxarifado/movimentacoes',
|
|
permission: { recurso: 'almoxarifado', acao: 'registrar_movimentacao' }
|
|
},
|
|
{
|
|
label: 'Requisições',
|
|
link: '/almoxarifado/requisicoes',
|
|
permission: { recurso: 'almoxarifado', acao: 'listar' }
|
|
},
|
|
{
|
|
label: 'Alertas',
|
|
link: '/almoxarifado/alertas',
|
|
permission: { recurso: 'almoxarifado', acao: 'listar' }
|
|
},
|
|
{
|
|
label: 'Relatórios',
|
|
link: '/almoxarifado/relatorios',
|
|
permission: { recurso: 'almoxarifado', acao: 'listar' }
|
|
}
|
|
]
|
|
},
|
|
{
|
|
label: 'Objetos',
|
|
icon: 'Tag',
|
|
link: '/compras/objetos',
|
|
permission: { recurso: 'objetos', acao: 'listar' }
|
|
},
|
|
{
|
|
label: 'Atas de Registro',
|
|
icon: 'FileText',
|
|
link: '/compras/atas',
|
|
permission: { recurso: 'atas', acao: 'listar' }
|
|
},
|
|
{
|
|
label: 'Contratos',
|
|
icon: 'FileText',
|
|
link: '/licitacoes/contratos',
|
|
permission: { recurso: 'contratos', acao: 'listar' }
|
|
},
|
|
{
|
|
label: 'Empresas',
|
|
icon: 'Briefcase',
|
|
link: '/licitacoes/empresas',
|
|
permission: { recurso: 'empresas', acao: 'listar' }
|
|
},
|
|
{
|
|
label: 'Fluxos & Processos',
|
|
icon: 'GitMerge',
|
|
link: '/fluxos',
|
|
permission: { recurso: 'fluxos_instancias', acao: 'listar' },
|
|
submenus: [
|
|
{
|
|
label: 'Meus Processos',
|
|
link: '/fluxos',
|
|
permission: { recurso: 'fluxos_instancias', acao: 'listar' },
|
|
exact: true
|
|
},
|
|
{
|
|
label: 'Modelos de Fluxo',
|
|
link: '/fluxos/templates',
|
|
permission: { recurso: 'fluxos_templates', acao: 'listar' }
|
|
}
|
|
]
|
|
},
|
|
{
|
|
label: 'Configurações',
|
|
icon: 'Settings',
|
|
link: '/configuracoes',
|
|
permission: { recurso: 'pedidos', acao: 'listar' },
|
|
submenus: [
|
|
{
|
|
label: 'Fluxo de Pedidos',
|
|
link: '/configuracoes/fluxo-pedidos',
|
|
permission: { recurso: 'pedidos', acao: 'listar' }
|
|
}
|
|
]
|
|
},
|
|
{
|
|
label: 'Painel de TI',
|
|
icon: 'Settings',
|
|
link: '/ti',
|
|
permission: { recurso: 'ti_painel_administrativo', acao: 'ver' }
|
|
}
|
|
] as const satisfies readonly MenuItem[];
|
|
|
|
type IconType = typeof Home;
|
|
|
|
type SidebarProps = {
|
|
onNavigate?: () => void;
|
|
};
|
|
|
|
const { onNavigate }: SidebarProps = $props();
|
|
|
|
let currentPath = $derived(page.url.pathname);
|
|
const permissionsQuery = useQuery(api.menu.getUserPermissions as FunctionReference<'query'>, {});
|
|
|
|
// Filtrar menu baseado nas permissões do usuário
|
|
function filterSubmenusByPermissions(
|
|
items: readonly SubMenuItem[],
|
|
isMaster: boolean,
|
|
permissionsSet: Set<string>
|
|
): SubMenuItem[] {
|
|
if (isMaster) return [...items];
|
|
|
|
return items.filter((item) => {
|
|
if (!item.permission) return true;
|
|
const key = `${item.permission.recurso}.${item.permission.acao}`;
|
|
return permissionsSet.has(key);
|
|
});
|
|
}
|
|
|
|
function filterMenuByPermissions(
|
|
items: readonly MenuItem[],
|
|
isMaster: boolean,
|
|
permissionsSet: Set<string>
|
|
): MenuItem[] {
|
|
if (isMaster) return [...items];
|
|
|
|
const filtered: MenuItem[] = [];
|
|
|
|
for (const item of items) {
|
|
// Verifica permissão do item atual
|
|
let hasPermission = true;
|
|
if (item.permission) {
|
|
const key = `${item.permission.recurso}.${item.permission.acao}`;
|
|
hasPermission = permissionsSet.has(key);
|
|
}
|
|
|
|
if (!hasPermission) continue;
|
|
|
|
// Se tiver submenus, filtra e só mantém se sobrar algo
|
|
let filteredSubmenus: SubMenuItem[] | undefined = undefined;
|
|
if (item.submenus) {
|
|
const subs = filterSubmenusByPermissions(item.submenus, isMaster, permissionsSet);
|
|
filteredSubmenus = subs.length > 0 ? subs : undefined;
|
|
}
|
|
|
|
filtered.push({
|
|
...item,
|
|
submenus: filteredSubmenus
|
|
});
|
|
}
|
|
|
|
return filtered;
|
|
}
|
|
|
|
// Menu filtrado reativo
|
|
let menuItems = $derived.by(() => {
|
|
const data = permissionsQuery.data;
|
|
if (!data) return [];
|
|
|
|
const permissionsSet = new Set((data.permissions ?? []) as string[]);
|
|
return filterMenuByPermissions(MENU_STRUCTURE, data.isMaster, permissionsSet);
|
|
});
|
|
|
|
const iconMap: Record<string, IconType> = {
|
|
Home,
|
|
UserPlus,
|
|
Users,
|
|
ClipboardCheck,
|
|
FileText,
|
|
Briefcase,
|
|
ChevronDown,
|
|
GitMerge,
|
|
Settings,
|
|
Tag,
|
|
Package
|
|
};
|
|
|
|
function getIconComponent(name: string): IconType {
|
|
return iconMap[name] || Home;
|
|
}
|
|
|
|
function isRouteActive(path: string, options: { exact?: boolean; excludePaths?: string[] } = {}) {
|
|
const { exact = false, excludePaths = [] } = options;
|
|
|
|
if (excludePaths.length > 0) {
|
|
if (excludePaths.some((excludePath) => currentPath.startsWith(excludePath))) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (exact) return currentPath === path;
|
|
return currentPath === path || currentPath.startsWith(path + '/');
|
|
}
|
|
|
|
function getMenuClasses(active: boolean, isSub = false, isParent = false) {
|
|
const base =
|
|
'flex items-center gap-3 rounded-r-md border-l-4 px-3 py-2.5 text-base font-medium transition-all duration-200';
|
|
|
|
// Premium active state with indicator
|
|
const activeLeafClass = 'border-primary bg-primary/5 text-primary font-semibold';
|
|
const activeParentClass = 'border-transparent text-primary font-semibold';
|
|
|
|
const inactiveClass =
|
|
'border-transparent text-base-content/70 hover:bg-primary/10 hover:text-primary';
|
|
const subClass = isSub ? 'pl-8 text-sm' : '';
|
|
|
|
if (active) {
|
|
return `${base} ${isParent ? activeParentClass : activeLeafClass} ${subClass}`;
|
|
}
|
|
|
|
return `${base} ${inactiveClass} ${subClass}`;
|
|
}
|
|
|
|
function getSolicitarClasses(active: boolean) {
|
|
return getMenuClasses(active);
|
|
}
|
|
</script>
|
|
|
|
<nav
|
|
class="menu text-base-content bg-base-200 border-base-100 h-[calc(100vh-64px)] w-full flex-col gap-2 overflow-y-auto p-4"
|
|
>
|
|
{#snippet menuItem(item: MenuItem)}
|
|
{@const Icon = getIconComponent(item.icon)}
|
|
{@const isActive = isRouteActive(item.link, {
|
|
exact: item.link === '/',
|
|
excludePaths: item.excludePaths
|
|
})}
|
|
{@const hasSubmenus = item.submenus && item.submenus.length > 0}
|
|
|
|
<li class="mb-1">
|
|
{#if hasSubmenus}
|
|
<details open={isActive} class="group/details">
|
|
<summary
|
|
class="{getMenuClasses(
|
|
isActive,
|
|
false,
|
|
true
|
|
)} cursor-pointer list-none justify-between [&::-webkit-details-marker]:hidden"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<Icon class="h-5 w-5" strokeWidth={2} />
|
|
<span>{item.label}</span>
|
|
</div>
|
|
<ChevronDown
|
|
class="h-4 w-4 opacity-50 transition-transform duration-200 group-open/details:rotate-180"
|
|
/>
|
|
</summary>
|
|
<ul class="border-base-200 mt-1 ml-4 space-y-1 pl-2">
|
|
{#if item.submenus}
|
|
{#each item.submenus as sub (sub.link)}
|
|
{@const isSubActive = isRouteActive(sub.link, {
|
|
excludePaths: sub.excludePaths,
|
|
exact: sub.exact
|
|
})}
|
|
<li>
|
|
<a
|
|
href={resolve(sub.link as any)}
|
|
class={getMenuClasses(isSubActive, true)}
|
|
onclick={() => onNavigate?.()}
|
|
>
|
|
<span>{sub.label}</span>
|
|
</a>
|
|
</li>
|
|
{/each}
|
|
{/if}
|
|
</ul>
|
|
</details>
|
|
{:else}
|
|
<a
|
|
href={resolve(item.link as any)}
|
|
aria-current={isActive ? 'page' : undefined}
|
|
class={getMenuClasses(isActive)}
|
|
onclick={() => onNavigate?.()}
|
|
>
|
|
<Icon class="h-5 w-5" strokeWidth={2} />
|
|
<span>{item.label}</span>
|
|
</a>
|
|
{/if}
|
|
</li>
|
|
{/snippet}
|
|
|
|
<ul class="menu w-full flex-1 p-0 px-2">
|
|
{#if permissionsQuery.isLoading}
|
|
<div class="flex flex-col gap-2 p-4">
|
|
{#each Array(5)}
|
|
<div class="skeleton h-12 w-full rounded-lg"></div>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
{#each menuItems as item (item.link)}
|
|
{@render menuItem(item)}
|
|
{/each}
|
|
{/if}
|
|
</ul>
|
|
|
|
<ul class="menu mt-auto w-full p-0 px-2">
|
|
<div class="divider before:bg-base-300 after:bg-base-300 my-2 px-2"></div>
|
|
|
|
<li class="px-2">
|
|
<a
|
|
href={resolve('/abrir-chamado')}
|
|
class={getSolicitarClasses(currentPath === '/abrir-chamado')}
|
|
onclick={() => onNavigate?.()}
|
|
>
|
|
<UserPlus class="h-5 w-5" strokeWidth={2} />
|
|
<span>Abrir Chamado</span>
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</nav>
|
|
|
|
<style>
|
|
/* Remove default details marker */
|
|
details > summary {
|
|
list-style: none;
|
|
}
|
|
details > summary::-webkit-details-marker {
|
|
display: none;
|
|
}
|
|
/* Remove DaisyUI default arrow */
|
|
details > summary::after {
|
|
display: none;
|
|
}
|
|
</style>
|