feat: Implement dynamic sidebar menu in the frontend, filtered by new backend user permissions.

This commit is contained in:
2025-12-04 16:05:26 -03:00
parent 2cdf66375c
commit 68475f549a
4 changed files with 527 additions and 269 deletions

View File

@@ -1,7 +1,32 @@
<script lang="ts"> <script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import { useConvexClient, useQuery } from 'convex-svelte'; import { useConvexClient, useQuery } from 'convex-svelte';
import { Check, Home, LogIn, Menu, Plus, Tag, User, UserPlus, XCircle } from 'lucide-svelte'; import type { FunctionReference } from 'convex/server';
import {
Home,
User,
UserPlus,
XCircle,
Users,
DollarSign,
ClipboardCheck,
FileText,
ShoppingCart,
Scale,
Megaphone,
Trophy,
Briefcase,
UserCog,
Monitor,
ChevronDown,
GitMerge,
Settings,
Check,
LogIn,
Menu,
Plus,
Tag
} from 'lucide-svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
@@ -14,15 +39,248 @@
import { loginModalStore } from '$lib/stores/loginModal.svelte'; import { loginModalStore } from '$lib/stores/loginModal.svelte';
import { obterIPPublico } from '$lib/utils/deviceInfo'; import { obterIPPublico } from '$lib/utils/deviceInfo';
interface GPSLocation {
latitude?: number;
longitude?: number;
precisao?: number;
endereco?: string;
cidade?: string;
estado?: string;
pais?: string;
}
interface MenuItemPermission {
recurso: string;
acao: string;
}
interface SubMenuItem {
label: string;
link: string;
permission?: MenuItemPermission;
}
interface MenuItem {
label: string;
icon: string;
link: string;
permission?: MenuItemPermission;
submenus?: SubMenuItem[];
}
// Estrutura do menu definida no frontend
const MENU_STRUCTURE: MenuItem[] = [
{
label: 'Dashboard',
icon: 'Home',
link: '/'
},
{
label: 'Gestão de Pessoas',
icon: 'Users',
link: '/recursos-humanos',
permission: { recurso: 'funcionarios', acao: 'ver' },
submenus: [
{
label: 'Funcionários',
link: '/recursos-humanos/funcionarios',
permission: { recurso: 'funcionarios', acao: 'listar' }
},
{
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: 'Todos os Pedidos',
link: '/pedidos',
permission: { recurso: 'pedidos', acao: 'listar' }
}
]
},
{
label: 'Objetos',
icon: 'Tag',
link: resolve('/compras/objetos'),
permission: { recurso: 'objetos', acao: 'listar' }
},
{
label: 'Atas de Registro',
icon: 'FileText',
link: resolve('/compras/atas'),
permission: { recurso: 'atas', acao: 'listar' }
},
{
label: 'Contratos',
icon: 'FileText',
link: resolve('/licitacoes/contratos'),
permission: { recurso: 'contratos', acao: 'listar' }
},
{
label: 'Empresas',
icon: 'Briefcase',
link: resolve('/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/meus-processos',
permission: { recurso: 'fluxos_instancias', acao: 'listar' }
},
{
label: 'Modelos de Fluxo',
link: '/fluxos/templates',
permission: { recurso: 'fluxos_templates', acao: 'listar' }
}
]
},
{
label: 'Painel de TI',
icon: 'Settings',
link: '/ti',
permission: { recurso: 'ti_painel_administrativo', acao: 'ver' }
}
];
type IconType = typeof Home;
const { children }: { children: Snippet } = $props(); const { children }: { children: Snippet } = $props();
let currentPath = $derived(page.url.pathname); let currentPath = $derived(page.url.pathname);
const currentUser = useQuery(api.auth.getCurrentUser, {}); let matricula = $state('');
let senha = $state('');
let erroLogin = $state('');
let carregandoLogin = $state(false);
let showAboutModal = $state(false);
const currentUser = useQuery(api.auth.getCurrentUser as FunctionReference<'query'>, {});
const permissionsQuery = useQuery(api.menu.getUserPermissions as FunctionReference<'query'>, {});
// Filtrar menu baseado nas permissões do usuário
function filterMenuByPermissions(
items: MenuItem[],
isMaster: boolean,
permissionsSet: Set<string>
): MenuItem[] {
if (isMaster) return items;
return items
.map((item) => {
// Verifica permissão do item atual
let hasPermission = true;
if (item.permission) {
const key = `${item.permission.recurso}.${item.permission.acao}`;
hasPermission = permissionsSet.has(key);
}
// Se não tem permissão, não mostra
if (!hasPermission) return null;
// Se tiver submenus, filtra eles recursivamente
let filteredSubmenus: MenuItem[] | undefined = undefined;
if (item.submenus) {
filteredSubmenus = filterMenuByPermissions(item.submenus, isMaster, permissionsSet);
}
return {
...item,
submenus: filteredSubmenus && filteredSubmenus.length > 0 ? filteredSubmenus : undefined
};
})
.filter((item): item is MenuItem => item !== null);
}
// Menu filtrado reativo
let menuItems = $derived.by(() => {
const data = permissionsQuery.data;
if (!data) return [];
const permissionsSet = new Set(data.permissions);
return filterMenuByPermissions(MENU_STRUCTURE, data.isMaster, permissionsSet);
});
const convexClient = useConvexClient(); const convexClient = useConvexClient();
const iconMap: Record<string, IconType> = {
Home,
User,
UserPlus,
XCircle,
Users,
DollarSign,
ClipboardCheck,
FileText,
ShoppingCart,
Scale,
Megaphone,
Trophy,
Briefcase,
UserCog,
Monitor,
ChevronDown,
GitMerge,
Settings,
Check,
LogIn,
Menu,
Plus,
Tag
};
function getIconComponent(name: string): IconType {
return iconMap[name] || Home;
}
function isRouteActive(path: string, exact = 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);
}
// Função para obter a URL do avatar/foto do usuário // Função para obter a URL do avatar/foto do usuário
let avatarUrlDoUsuario = $derived(() => { let avatarUrlDoUsuario = $derived.by(() => {
if (!currentUser.data) return null; if (!currentUser.data) return null;
// Prioridade: fotoPerfilUrl > avatar > fallback com nome // Prioridade: fotoPerfilUrl > avatar > fallback com nome
@@ -38,62 +296,6 @@
return null; return null;
}); });
// Função para gerar classes do menu ativo
function getMenuClasses(isActive: boolean) {
const baseClasses =
'group font-semibold flex items-center justify-center gap-2 text-center p-3.5 rounded-xl border-2 transition-all duration-300 shadow-md hover:shadow-lg hover:scale-105';
if (isActive) {
return `${baseClasses} border-primary bg-primary text-white shadow-lg scale-105`;
}
return `${baseClasses} border-primary/30 bg-linear-to-br from-base-100 to-base-200 text-base-content hover:from-primary hover:to-primary/80 hover:text-white`;
}
// Função para gerar classes do botão "Solicitar Acesso"
function getSolicitarClasses(isActive: boolean) {
const baseClasses =
'group font-semibold flex items-center justify-center gap-2 text-center p-3.5 rounded-xl border-2 transition-all duration-300 shadow-md hover:shadow-lg hover:scale-105';
if (isActive) {
return `${baseClasses} border-success bg-success text-white shadow-lg scale-105`;
}
return `${baseClasses} border-success/30 bg-linear-to-br from-success/10 to-success/20 text-base-content hover:from-success hover:to-success/80 hover:text-white`;
}
const setores = [
{ nome: 'Recursos Humanos', link: '/recursos-humanos' },
{ nome: 'Financeiro', link: '/financeiro' },
{ nome: 'Controladoria', link: '/controladoria' },
{ nome: 'Licitações', link: '/licitacoes' },
{ nome: 'Compras', link: '/compras' },
{ nome: 'Jurídico', link: '/juridico' },
{ nome: 'Comunicação', link: '/comunicacao' },
{ nome: 'Programas Esportivos', link: '/programas-esportivos' },
{ nome: 'Secretaria Executiva', link: '/secretaria-executiva' },
{
nome: 'Secretaria de Gestão de Pessoas',
link: '/gestao-pessoas'
},
{ nome: 'Tecnologia da Informação', link: '/ti' }
];
let showAboutModal = $state(false);
let matricula = $state('');
let senha = $state('');
let erroLogin = $state('');
let carregandoLogin = $state(false);
// Sincronizar com o store global
$effect(() => {
if (loginModalStore.showModal && !matricula && !senha) {
matricula = '';
senha = '';
erroLogin = '';
}
});
function openLoginModal() { function openLoginModal() {
loginModalStore.open(); loginModalStore.open();
matricula = ''; matricula = '';
@@ -134,12 +336,14 @@
const ipPublico = await Promise.race([ipPublicoPromise, ipPublicoTimeout]); const ipPublico = await Promise.race([ipPublicoPromise, ipPublicoTimeout]);
// Função para coletar GPS em background (não bloqueia login) // Função para coletar GPS em background (não bloqueia login)
async function coletarGPS(): Promise<any> { async function coletarGPS(): Promise<GPSLocation> {
try { try {
const { obterLocalizacaoRapida } = await import('$lib/utils/deviceInfo'); const { obterLocalizacaoRapida } = await import('$lib/utils/deviceInfo');
// Usar versão rápida com timeout curto (3 segundos máximo) // Usar versão rápida com timeout curto (3 segundos máximo)
const gpsPromise = obterLocalizacaoRapida(); const gpsPromise = obterLocalizacaoRapida();
const gpsTimeout = new Promise<{}>((resolve) => setTimeout(() => resolve({}), 3000)); const gpsTimeout = new Promise<GPSLocation>((resolve) =>
setTimeout(() => resolve({}), 3000)
);
return await Promise.race([gpsPromise, gpsTimeout]); return await Promise.race([gpsPromise, gpsTimeout]);
} catch (err) { } catch (err) {
console.warn('Erro ao obter GPS (não bloqueia login):', err); console.warn('Erro ao obter GPS (não bloqueia login):', err);
@@ -157,11 +361,11 @@
// Registrar tentativa de login falha // Registrar tentativa de login falha
try { try {
// Tentar obter GPS se já estiver disponível (não esperar) // Tentar obter GPS se já estiver disponível (não esperar)
let localizacaoGPS: any = {}; let localizacaoGPS: GPSLocation = {};
try { try {
localizacaoGPS = await Promise.race([ localizacaoGPS = await Promise.race([
gpsPromise, gpsPromise,
new Promise<{}>((resolve) => setTimeout(() => resolve({}), 100)) new Promise<GPSLocation>((resolve) => setTimeout(() => resolve({}), 100))
]); ]);
} catch { } catch {
// Ignorar se GPS não estiver pronto // Ignorar se GPS não estiver pronto
@@ -198,11 +402,11 @@
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
// Tentar obter GPS se já estiver disponível (não esperar) // Tentar obter GPS se já estiver disponível (não esperar)
let localizacaoGPS: any = {}; let localizacaoGPS: GPSLocation = {};
try { try {
localizacaoGPS = await Promise.race([ localizacaoGPS = await Promise.race([
gpsPromise, gpsPromise,
new Promise<{}>((resolve) => setTimeout(() => resolve({}), 100)) new Promise<GPSLocation>((resolve) => setTimeout(() => resolve({}), 100))
]); ]);
} catch { } catch {
// Ignorar se GPS não estiver pronto // Ignorar se GPS não estiver pronto
@@ -269,232 +473,218 @@
</script> </script>
<!-- Header Fixo acima de tudo --> <!-- Header Fixo acima de tudo -->
<!-- Header Fixo Minimalista & Premium -->
<div <div
class="navbar from-primary/30 via-primary/20 to-primary/30 border-primary/10 fixed top-0 right-0 left-0 z-50 min-h-24 border-b bg-linear-to-r px-6 shadow-lg backdrop-blur-sm lg:px-8" class="navbar border-primary/10 from-primary/10 via-primary/5 to-primary/10 fixed top-0 right-0 left-0 z-50 h-16 border-b bg-linear-to-r px-4 shadow-sm"
> >
<div class="flex-none lg:hidden"> <div class="flex-none lg:hidden">
<label <label for="my-drawer-3" class="btn btn-square btn-ghost btn-sm" aria-label="Abrir menu">
for="my-drawer-3" <Menu class="h-5 w-5" />
class="group bg-primary relative flex h-14 w-14 cursor-pointer items-center justify-center overflow-hidden rounded-2xl shadow-xl transition-all duration-300 hover:scale-105 hover:shadow-2xl"
aria-label="Abrir menu"
>
<!-- Efeito de brilho no hover -->
<div
class="absolute inset-0 bg-linear-to-br from-white/0 to-white/20 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
></div>
<!-- Ícone de menu hambúrguer -->
<Menu
class="relative z-10 h-7 w-7 text-white transition-transform duration-300 group-hover:scale-110"
style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3));"
strokeWidth={2.5}
/>
</label> </label>
</div> </div>
<div class="flex flex-1 items-center gap-4 lg:gap-6"> <div class="flex flex-1 items-center gap-4 lg:gap-6">
<!-- Logo MODERNO do Governo --> <!-- Logo Visível e Ajustado -->
<div class="avatar"> <div class="flex items-center gap-3">
<div <div class="relative h-10 overflow-hidden rounded-lg shadow-sm lg:h-14">
class="group bg-base-100 border-primary/20 relative w-16 overflow-hidden rounded-2xl border-2 p-2 shadow-xl transition-all duration-300 hover:scale-105 lg:w-20" <img src={logo} alt="Logo do Governo de PE" class="h-full w-full object-contain p-1" />
</div>
<div class="flex flex-col justify-center">
<h1 class="text-base-content text-xl leading-none font-bold tracking-tight lg:text-2xl">
SGSE
</h1>
<span class="text-base-content/60 font-medium tracking-wider uppercase"
>Secretaria de Esportes</span
> >
<!-- Efeito de brilho no hover -->
<div
class="from-primary/5 absolute inset-0 bg-linear-to-br to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"
></div>
<!-- Logo -->
<img
src={logo}
alt="Logo do Governo de PE"
class="relative z-10 h-full w-full object-contain transition-transform duration-300 group-hover:scale-105"
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.08));"
/>
<!-- Brilho sutil no canto -->
<div
class="absolute top-0 right-0 h-8 w-8 rounded-bl-full bg-linear-to-br from-white/40 to-transparent opacity-70"
></div>
</div> </div>
</div> </div>
<div class="flex flex-col">
<h1 class="text-primary text-xl font-bold tracking-tight lg:text-3xl">SGSE</h1>
<p
class="text-base-content/80 hidden text-xs leading-tight font-medium sm:block lg:text-base"
>
Sistema de Gerenciamento de Secretaria
</p>
</div> </div>
</div> <div class="ml-auto flex flex-none items-center gap-3 lg:gap-5">
<div class="ml-auto flex flex-none items-center gap-4">
{#if currentUser.data} {#if currentUser.data}
<!-- Nome e Perfil à esquerda do avatar --> <!-- Nome e Perfil -->
<div class="hidden flex-col items-end lg:flex"> <div class="hidden flex-col items-end lg:flex">
<span class="text-primary text-sm font-semibold">{currentUser.data.nome}</span> <span class="text-base-content text-sm leading-tight font-semibold"
<span class="text-base-content/60 text-xs">{currentUser.data.role?.nome}</span> >{currentUser.data.nome}</span
>
<span class="text-base-content/60 text-xs leading-tight">{currentUser.data.role?.nome}</span
>
</div> </div>
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<!-- Botão de Perfil ULTRA MODERNO --> <!-- Botão de Perfil com Avatar -->
<button <button
type="button" type="button"
tabindex="0" tabindex="0"
class="group bg-primary relative flex h-14 w-14 items-center justify-center overflow-hidden rounded-2xl shadow-xl transition-all duration-300 hover:scale-105 hover:shadow-2xl" class="btn avatar ring-base-200 hover:ring-primary/50 h-12 w-12 p-0 ring-2 ring-offset-2 transition-all"
aria-label="Menu do usuário" aria-label="Menu do usuário"
> >
<!-- Efeito de brilho no hover --> <div class="h-full w-full overflow-hidden rounded-full">
<div {#if avatarUrlDoUsuario}
class="absolute inset-0 bg-linear-to-br from-white/0 to-white/20 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
></div>
<!-- Avatar/Foto do usuário ou ícone padrão -->
{#if avatarUrlDoUsuario()}
<img <img
src={avatarUrlDoUsuario()} src={avatarUrlDoUsuario}
alt={currentUser.data?.nome || 'Usuário'} alt={currentUser.data?.nome || 'Usuário'}
class="relative z-10 h-full w-full object-cover" class="h-full w-full object-cover"
/> />
{:else} {:else}
<!-- Ícone de usuário moderno (fallback) --> <div
<User class="bg-primary/10 text-primary flex h-full w-full items-center justify-center"
class="relative z-10 h-7 w-7 text-white transition-transform duration-300 group-hover:scale-110" >
style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3));" <User class="h-6 w-6" />
/> </div>
{/if} {/if}
</div>
<!-- Anel de pulso sutil -->
<div
class="absolute inset-0 rounded-2xl"
style="animation: pulse-ring-subtle 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;"
></div>
<!-- Badge de status online -->
<div
class="bg-success absolute top-1 right-1 z-20 h-3 w-3 rounded-full border-2 border-white shadow-lg"
style="animation: pulse-dot 2s ease-in-out infinite;"
></div>
</button> </button>
<!-- svelte-ignore a11y_no_noninteractive_tabindex --> <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul <ul
tabindex="0" tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box border-primary/20 z-1 mt-4 w-52 border p-2 shadow-xl" class="dropdown-content menu bg-base-100 rounded-box ring-base-content/5 z-1 mt-3 w-56 p-2 shadow-xl ring-1"
> >
<li class="menu-title"> <li class="menu-title border-base-200 mb-2 border-b px-4 py-2">
<span class="text-primary font-bold">{currentUser.data?.nome}</span> <span class="text-base-content font-bold">{currentUser.data?.nome}</span>
<span class="text-base-content/60 text-xs font-normal">{currentUser.data.email}</span>
</li> </li>
<li><a href={resolve('/perfil')}>Meu Perfil</a></li>
<li><a href={resolve('/alterar-senha')}>Alterar Senha</a></li>
<div class="divider my-0"></div>
<li> <li>
<button type="button" onclick={handleLogout} class="text-error">Sair</button> <a href={resolve('/perfil')} class="active:bg-primary/10 active:text-primary"
><UserCog class="mr-2 h-4 w-4" /> Meu Perfil</a
>
</li>
<li>
<a href={resolve('/alterar-senha')} class="active:bg-primary/10 active:text-primary"
><Settings class="mr-2 h-4 w-4" /> Alterar Senha</a
>
</li>
<div class="divider my-1"></div>
<li>
<button type="button" onclick={handleLogout} class="text-error hover:bg-error/10"
><LogIn class="mr-2 h-4 w-4 rotate-180" /> Sair</button
>
</li> </li>
</ul> </ul>
</div> </div>
<!-- Sino de notificações no canto superior direito --> <!-- Sino de notificações -->
<div class="relative"> <div class="relative">
<NotificationBell /> <NotificationBell />
</div> </div>
{:else} {:else}
<button <button
type="button" type="button"
class="btn btn-lg hover:shadow-primary/30 group from-primary via-primary to-primary/80 hover:from-primary/90 hover:via-primary/80 hover:to-primary/70 relative overflow-hidden border-0 bg-linear-to-br shadow-2xl transition-all duration-500 hover:scale-110" class="btn btn-primary btn-sm rounded-full px-6"
style="width: 4rem; height: 4rem; border-radius: 9999px;"
onclick={() => openLoginModal()} onclick={() => openLoginModal()}
aria-label="Login"
> >
<!-- Efeito de brilho animado --> Entrar
<div
class="absolute inset-0 -translate-x-full bg-linear-to-r from-transparent via-white/30 to-transparent transition-transform duration-1000 group-hover:translate-x-full"
></div>
<!-- Anel pulsante de fundo -->
<div class="absolute inset-0 rounded-full bg-white/10 group-hover:animate-ping"></div>
<!-- Ícone de login premium -->
<User
class="relative z-10 h-8 w-8 text-white transition-all duration-500 group-hover:scale-110"
style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3));"
strokeWidth={2.5}
/>
</button> </button>
{/if} {/if}
</div> </div>
</div> </div>
<div class="drawer lg:drawer-open" style="margin-top: 96px;"> <div class="drawer lg:drawer-open" style="margin-top: 64px;">
<input id="my-drawer-3" type="checkbox" class="drawer-toggle" /> <input id="my-drawer-3" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col lg:ml-72" style="min-height: calc(100vh - 96px);"> <div
class="drawer-content flex flex-col transition-all duration-300 lg:ml-72"
style="min-height: calc(100vh - 64px);"
>
<!-- Page content --> <!-- Page content -->
<div class="flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto p-6">
{@render children?.()} {@render children?.()}
</div> </div>
<!-- Footer --> <!-- Footer Minimalista -->
<footer <footer
class="footer footer-center from-primary/30 via-primary/20 to-primary/30 text-base-content border-primary/20 shrink-0 border-t-2 bg-linear-to-r p-6 shadow-inner backdrop-blur-sm" class="footer footer-center text-base-content/60 border-base-200 bg-base-100/50 border-t p-4 text-xs backdrop-blur-sm"
> >
<div class="grid grid-flow-col gap-6 text-sm font-medium"> <div class="flex flex-wrap justify-center gap-6">
<button <button
type="button" type="button"
class="link link-hover hover:text-primary transition-colors" class="hover:text-primary font-medium transition-colors"
onclick={() => openAboutModal()}>Sobre</button onclick={() => openAboutModal()}>Sobre</button
> >
<span class="text-base-content/30"></span> <a href={resolve('/')} class="hover:text-primary font-medium transition-colors">Contato</a>
<a href={resolve('/')} class="link link-hover hover:text-primary transition-colors" <a href={resolve('/abrir-chamado')} class="hover:text-primary font-medium transition-colors"
>Contato</a >Suporte</a
> >
<span class="text-base-content/30"></span> <a href={resolve('/')} class="hover:text-primary font-medium transition-colors"
<a
href={resolve('/abrir-chamado')}
class="link link-hover hover:text-primary transition-colors">Suporte</a
>
<span class="text-base-content/30"></span>
<a href={resolve('/')} class="link link-hover hover:text-primary transition-colors"
>Privacidade</a >Privacidade</a
> >
</div> </div>
<div class="mt-2 flex items-center gap-3"> <div class="mt-2 opacity-70">
<div class="avatar"> <p>© {new Date().getFullYear()} Governo de Pernambuco - Secretaria de Esportes</p>
<div class="w-10 rounded-lg bg-white p-1.5 shadow-md">
<img src={logo} alt="Logo" class="h-full w-full object-contain" />
</div> </div>
</div>
<div class="text-left">
<p class="text-primary text-xs font-bold">Governo do Estado de Pernambuco</p>
<p class="text-base-content/70 text-xs">Secretaria de Esportes</p>
</div>
</div>
<p class="text-base-content/60 mt-2 text-xs">
© {new Date().getFullYear()} - Todos os direitos reservados
</p>
</footer> </footer>
</div> </div>
<div class="drawer-side fixed z-40" style="margin-top: 96px;"> <div class="drawer-side fixed z-40" style="margin-top: 64px;">
<label for="my-drawer-3" aria-label="close sidebar" class="drawer-overlay"></label> <label for="my-drawer-3" aria-label="close sidebar" class="drawer-overlay"></label>
<div <div
class="menu from-primary/25 to-primary/15 border-primary/20 flex h-[calc(100vh-96px)] w-72 flex-col gap-2 overflow-y-auto border-r-2 bg-linear-to-b p-4 shadow-xl backdrop-blur-sm" class="menu text-base-content border-base-300 from-primary/10 via-primary/5 to-primary/10 h-[calc(100vh-64px)] w-72 flex-col gap-2 overflow-y-auto border-r-2 bg-linear-to-b p-4 backdrop-blur-sm"
> >
<!-- Sidebar menu items --> <!-- Sidebar menu items -->
<ul class="flex flex-col gap-2"> <!-- Sidebar menu items -->
<li class="rounded-xl"> {#snippet menuItem(item: MenuItem)}
<a href={resolve('/')} class={getMenuClasses(currentPath === '/')}> {@const Icon = getIconComponent(item.icon)}
<Home class="h-5 w-5 transition-transform group-hover:scale-110" strokeWidth={2} /> {@const isActive = isRouteActive(item.link, item.link === '/')}
<span>Dashboard</span> {@const hasSubmenus = item.submenus && item.submenus.length > 0}
</a>
</li> <li class="mb-1">
{#each setores as s (s.link)} {#if hasSubmenus}
{@const isActive = currentPath.startsWith(s.link)} <details open={isActive} class="group/details">
<li class="rounded-xl"> <summary
<a class="{getMenuClasses(
href={resolve(s.link)} isActive,
aria-current={isActive ? 'page' : undefined} false,
class={getMenuClasses(isActive)} true
)} cursor-pointer list-none justify-between [&::-webkit-details-marker]:hidden"
> >
<span>{s.nome}</span> <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 border-l-2 pl-2">
{#if item.submenus}
{#each item.submenus as sub (sub.link)}
{@const isSubActive = isRouteActive(sub.link)}
<li>
<a href={resolve(sub.link)} class={getMenuClasses(isSubActive, true)}>
<span>{sub.label}</span>
</a> </a>
</li> </li>
{/each} {/each}
<li class="mt-auto rounded-xl"> {/if}
</ul>
</details>
{:else}
<a
href={resolve(item.link)}
aria-current={isActive ? 'page' : undefined}
class={getMenuClasses(isActive)}
>
<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 <a
href={resolve('/abrir-chamado')} href={resolve('/abrir-chamado')}
class={getSolicitarClasses(currentPath === '/abrir-chamado')} class={getSolicitarClasses(currentPath === '/abrir-chamado')}
@@ -512,7 +702,7 @@
{#if loginModalStore.showModal} {#if loginModalStore.showModal}
<dialog class="modal modal-open"> <dialog class="modal modal-open">
<div <div
class="modal-box from-base-100 via-base-100 to-primary/5 relative max-w-md overflow-hidden bg-gradient-to-br shadow-2xl backdrop-blur-sm" class="modal-box from-base-100 via-base-100 to-primary/5 relative max-w-md overflow-hidden bg-linear-to-br shadow-2xl backdrop-blur-sm"
> >
<!-- Botão de fechar moderno --> <!-- Botão de fechar moderno -->
<button <button
@@ -536,7 +726,7 @@
class="group ring-primary/20 relative w-24 overflow-hidden rounded-2xl bg-white p-4 shadow-xl ring-2 transition-all duration-300 hover:scale-105 hover:shadow-2xl" class="group ring-primary/20 relative w-24 overflow-hidden rounded-2xl bg-white p-4 shadow-xl ring-2 transition-all duration-300 hover:scale-105 hover:shadow-2xl"
> >
<div <div
class="from-primary/10 absolute inset-0 bg-gradient-to-br to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100" class="from-primary/10 absolute inset-0 bg-linear-to-br to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"
></div> ></div>
<img src={logo} alt="Logo SGSE" class="relative z-10 h-full w-full object-contain" /> <img src={logo} alt="Logo SGSE" class="relative z-10 h-full w-full object-contain" />
</div> </div>
@@ -601,12 +791,12 @@
<div class="form-control pt-2"> <div class="form-control pt-2">
<button <button
type="submit" type="submit"
class="btn btn-primary btn-lg group from-primary via-primary to-primary/90 relative w-full overflow-hidden border-0 bg-gradient-to-r shadow-xl transition-all duration-300 hover:scale-[1.02] hover:shadow-2xl disabled:opacity-50" class="btn btn-primary btn-lg group from-primary via-primary to-primary/90 relative w-full overflow-hidden border-0 bg-linear-to-r shadow-xl transition-all duration-300 hover:scale-[1.02] hover:shadow-2xl disabled:opacity-50"
disabled={carregandoLogin} disabled={carregandoLogin}
> >
<!-- Efeito de brilho animado --> <!-- Efeito de brilho animado -->
<div <div
class="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/30 to-transparent transition-transform duration-1000 group-hover:translate-x-full" class="absolute inset-0 -translate-x-full bg-linear-to-r from-transparent via-white/30 to-transparent transition-transform duration-1000 group-hover:translate-x-full"
></div> ></div>
{#if carregandoLogin} {#if carregandoLogin}
@@ -654,7 +844,7 @@
{#if showAboutModal} {#if showAboutModal}
<dialog class="modal modal-open"> <dialog class="modal modal-open">
<div <div
class="modal-box from-base-100 to-base-200 relative max-w-md overflow-hidden bg-gradient-to-br shadow-xl" class="modal-box from-base-100 to-base-200 relative max-w-md overflow-hidden bg-linear-to-br shadow-xl"
> >
<button <button
type="button" type="button"
@@ -685,7 +875,7 @@
<!-- Informações de Versão --> <!-- Informações de Versão -->
<div <div
class="from-primary/10 to-primary/5 border-primary/10 space-y-2 rounded-xl border bg-gradient-to-br p-4 shadow-sm" class="from-primary/10 to-primary/5 border-primary/10 space-y-2 rounded-xl border bg-linear-to-br p-4 shadow-sm"
> >
<div class="flex items-center justify-center gap-2"> <div class="flex items-center justify-center gap-2">
<Tag class="text-primary h-4 w-4" strokeWidth={2} /> <Tag class="text-primary h-4 w-4" strokeWidth={2} />
@@ -780,4 +970,16 @@
transform: scale(1.1); transform: scale(1.1);
} }
} }
/* 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> </style>

View File

@@ -1,41 +1,38 @@
<script lang="ts"> <script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient } from 'convex-svelte'; import { useQuery, useConvexClient } from 'convex-svelte';
import { onMount } from 'svelte';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
const client = useConvexClient(); const client = useConvexClient();
let isLoading = true; const simbolosQuery = useQuery(api.simbolos.getAll, {});
let list: Array<any> = [];
const filtroNome = ''; let list = $derived(simbolosQuery.data ?? []);
const filtroTipo: '' | 'cargo_comissionado' | 'funcao_gratificada' = ''; let isLoading = $derived(simbolosQuery.isLoading);
const filtroDescricao = '';
let filtered: Array<any> = []; let filtroNome = $state('');
let notice: { kind: 'success' | 'error'; text: string } | null = null; let filtroTipo = $state<'' | 'cargo_comissionado' | 'funcao_gratificada'>('');
$: needsScroll = filtered.length > 8; let filtroDescricao = $state('');
let openMenuId: string | null = null; let notice = $state<{ kind: 'success' | 'error'; text: string } | null>(null);
function toggleMenu(id: string) { let openMenuId = $state<string | null>(null);
openMenuId = openMenuId === id ? null : id;
} let filtered = $derived(
$: filtered = (list ?? []).filter((s) => { list.filter((s) => {
const nome = (filtroNome || '').toLowerCase(); const nome = filtroNome.toLowerCase();
const desc = (filtroDescricao || '').toLowerCase(); const desc = filtroDescricao.toLowerCase();
const okNome = !nome || (s.nome || '').toLowerCase().includes(nome); const okNome = !nome || (s.nome || '').toLowerCase().includes(nome);
const okDesc = !desc || (s.descricao || '').toLowerCase().includes(desc); const okDesc = !desc || (s.descricao || '').toLowerCase().includes(desc);
const okTipo = !filtroTipo || s.tipo === filtroTipo; const okTipo = !filtroTipo || s.tipo === filtroTipo;
return okNome && okDesc && okTipo; return okNome && okDesc && okTipo;
}); })
onMount(async () => { );
try {
list = await client.query(api.simbolos.getAll, {} as any);
} finally {
isLoading = false;
}
});
let deletingId: Id<'simbolos'> | null = null; function toggleMenu(id: string) {
let simboloToDelete: { id: Id<'simbolos'>; nome: string } | null = null; openMenuId = openMenuId === id ? null : id;
}
let deletingId = $state<Id<'simbolos'> | null>(null);
let simboloToDelete = $state<{ id: Id<'simbolos'>; nome: string } | null>(null);
function openDeleteModal(id: Id<'simbolos'>, nome: string) { function openDeleteModal(id: Id<'simbolos'>, nome: string) {
simboloToDelete = { id, nome }; simboloToDelete = { id, nome };
@@ -52,19 +49,17 @@
try { try {
deletingId = simboloToDelete.id; deletingId = simboloToDelete.id;
await client.mutation(api.simbolos.remove, { id: simboloToDelete.id }); await client.mutation(api.simbolos.remove, { id: simboloToDelete.id });
// reload list
list = await client.query(api.simbolos.getAll, {} as any);
notice = { kind: 'success', text: 'Símbolo excluído com sucesso.' }; notice = { kind: 'success', text: 'Símbolo excluído com sucesso.' };
closeDeleteModal(); closeDeleteModal();
} catch (error) { } catch {
notice = { kind: 'error', text: 'Erro ao excluir símbolo.' }; notice = { kind: 'error', text: 'Erro ao excluir símbolo.' };
} finally { } finally {
deletingId = null; deletingId = null;
} }
} }
function formatMoney(value: string) { function formatMoney(value: string | number) {
const num = parseFloat(value); const num = typeof value === 'number' ? value : parseFloat(value);
if (isNaN(num)) return 'R$ 0,00'; if (isNaN(num)) return 'R$ 0,00';
return `R$ ${num.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; return `R$ ${num.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
} }
@@ -72,6 +67,12 @@
function getTipoLabel(tipo: string) { function getTipoLabel(tipo: string) {
return tipo === 'cargo_comissionado' ? 'Cargo Comissionado' : 'Função Gratificada'; return tipo === 'cargo_comissionado' ? 'Cargo Comissionado' : 'Função Gratificada';
} }
function clearFilters() {
filtroNome = '';
filtroTipo = '';
filtroDescricao = '';
}
</script> </script>
<main class="container mx-auto px-4 py-4"> <main class="container mx-auto px-4 py-4">
@@ -223,14 +224,7 @@
</div> </div>
{#if filtroNome || filtroTipo || filtroDescricao} {#if filtroNome || filtroTipo || filtroDescricao}
<div class="mt-4"> <div class="mt-4">
<button <button class="btn btn-sm gap-2" onclick={clearFilters}>
class="btn btn-sm gap-2"
onclick={() => {
filtroNome = '';
filtroTipo = '';
filtroDescricao = '';
}}
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4" class="h-4 w-4"
@@ -279,7 +273,7 @@
</thead> </thead>
<tbody> <tbody>
{#if filtered.length > 0} {#if filtered.length > 0}
{#each filtered as simbolo} {#each filtered as simbolo (simbolo._id)}
<tr class="hover"> <tr class="hover">
<td class="font-medium">{simbolo.nome}</td> <td class="font-medium">{simbolo.nome}</td>
<td> <td>
@@ -304,6 +298,7 @@
type="button" type="button"
class="btn btn-sm" class="btn btn-sm"
onclick={() => toggleMenu(simbolo._id)} onclick={() => toggleMenu(simbolo._id)}
aria-label="Menu de ações"
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View File

@@ -45,6 +45,7 @@ import type * as http from "../http.js";
import type * as logsAcesso from "../logsAcesso.js"; import type * as logsAcesso from "../logsAcesso.js";
import type * as logsAtividades from "../logsAtividades.js"; import type * as logsAtividades from "../logsAtividades.js";
import type * as logsLogin from "../logsLogin.js"; import type * as logsLogin from "../logsLogin.js";
import type * as menu from "../menu.js";
import type * as monitoramento from "../monitoramento.js"; import type * as monitoramento from "../monitoramento.js";
import type * as objetos from "../objetos.js"; import type * as objetos from "../objetos.js";
import type * as pedidos from "../pedidos.js"; import type * as pedidos from "../pedidos.js";
@@ -132,6 +133,7 @@ declare const fullApi: ApiFromModules<{
logsAcesso: typeof logsAcesso; logsAcesso: typeof logsAcesso;
logsAtividades: typeof logsAtividades; logsAtividades: typeof logsAtividades;
logsLogin: typeof logsLogin; logsLogin: typeof logsLogin;
menu: typeof menu;
monitoramento: typeof monitoramento; monitoramento: typeof monitoramento;
objetos: typeof objetos; objetos: typeof objetos;
pedidos: typeof pedidos; pedidos: typeof pedidos;

View File

@@ -0,0 +1,59 @@
import { query } from './_generated/server';
import { getCurrentUserFunction } from './auth';
/**
* Retorna as permissões do usuário atual para o frontend filtrar o menu localmente
* Retorna:
* - isMaster: true se o usuário é TI Master ou Admin (nível <= 1)
* - permissions: Set de strings no formato "recurso.acao" (ex: "funcionarios.listar")
*/
export const getUserPermissions = query({
args: {},
handler: async (ctx) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
return { isMaster: false, permissions: [] };
}
const role = await ctx.db.get(usuario.roleId);
if (!role) {
return { isMaster: false, permissions: [] };
}
// Se for TI Master ou Admin (nivel <= 1), retorna flag de master
if (role.nivel <= 1) {
return { isMaster: true, permissions: [] };
}
// Buscar permissões do usuário
const rolePermissoes = await ctx.db
.query('rolePermissoes')
.withIndex('by_role', (q) => q.eq('roleId', role._id))
.collect();
const permissoesIds = rolePermissoes.map((rp) => rp.permissaoId);
// Carregar os documentos de permissão para saber recurso/ação
const permissoesDocs = await Promise.all(permissoesIds.map((id) => ctx.db.get(id)));
// Criar array de "recurso.acao" para o frontend
const permissions: string[] = [];
for (const p of permissoesDocs) {
if (p) {
permissions.push(`${p.recurso}.${p.acao}`);
}
}
return { isMaster: false, permissions };
}
});
// Manter a query antiga por compatibilidade (deprecada)
// TODO: Remover após migração completa do frontend
export const getSidebarMenu = query({
args: {},
handler: async () => {
// Retorna array vazio - o frontend agora define o menu
return [];
}
});