feat: Implement dynamic sidebar menu in the frontend, filtered by new backend user permissions.
This commit is contained in:
@@ -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>
|
||||||
<!-- Efeito de brilho no hover -->
|
<div class="flex flex-col justify-center">
|
||||||
<div
|
<h1 class="text-base-content text-xl leading-none font-bold tracking-tight lg:text-2xl">
|
||||||
class="from-primary/5 absolute inset-0 bg-linear-to-br to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
SGSE
|
||||||
></div>
|
</h1>
|
||||||
|
<span class="text-base-content/60 font-medium tracking-wider uppercase"
|
||||||
<!-- Logo -->
|
>Secretaria de Esportes</span
|
||||||
<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-4">
|
<div class="ml-auto flex flex-none items-center gap-3 lg:gap-5">
|
||||||
{#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"
|
<img
|
||||||
></div>
|
src={avatarUrlDoUsuario}
|
||||||
|
alt={currentUser.data?.nome || 'Usuário'}
|
||||||
<!-- Avatar/Foto do usuário ou ícone padrão -->
|
class="h-full w-full object-cover"
|
||||||
{#if avatarUrlDoUsuario()}
|
/>
|
||||||
<img
|
{:else}
|
||||||
src={avatarUrlDoUsuario()}
|
<div
|
||||||
alt={currentUser.data?.nome || 'Usuário'}
|
class="bg-primary/10 text-primary flex h-full w-full items-center justify-center"
|
||||||
class="relative z-10 h-full w-full object-cover"
|
>
|
||||||
/>
|
<User class="h-6 w-6" />
|
||||||
{:else}
|
</div>
|
||||||
<!-- Ícone de usuário moderno (fallback) -->
|
{/if}
|
||||||
<User
|
</div>
|
||||||
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));"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- 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 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>
|
</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
|
||||||
|
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 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>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{:else}
|
||||||
<a
|
<a
|
||||||
href={resolve(s.link)}
|
href={resolve(item.link)}
|
||||||
aria-current={isActive ? 'page' : undefined}
|
aria-current={isActive ? 'page' : undefined}
|
||||||
class={getMenuClasses(isActive)}
|
class={getMenuClasses(isActive)}
|
||||||
>
|
>
|
||||||
<span>{s.nome}</span>
|
<Icon class="h-5 w-5" strokeWidth={2} />
|
||||||
|
<span>{item.label}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
{/if}
|
||||||
{/each}
|
</li>
|
||||||
<li class="mt-auto rounded-xl">
|
{/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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
let openMenuId = $state<string | null>(null);
|
||||||
|
|
||||||
|
let filtered = $derived(
|
||||||
|
list.filter((s) => {
|
||||||
|
const nome = filtroNome.toLowerCase();
|
||||||
|
const desc = filtroDescricao.toLowerCase();
|
||||||
|
const okNome = !nome || (s.nome || '').toLowerCase().includes(nome);
|
||||||
|
const okDesc = !desc || (s.descricao || '').toLowerCase().includes(desc);
|
||||||
|
const okTipo = !filtroTipo || s.tipo === filtroTipo;
|
||||||
|
return okNome && okDesc && okTipo;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
function toggleMenu(id: string) {
|
function toggleMenu(id: string) {
|
||||||
openMenuId = openMenuId === id ? null : id;
|
openMenuId = openMenuId === id ? null : id;
|
||||||
}
|
}
|
||||||
$: filtered = (list ?? []).filter((s) => {
|
|
||||||
const nome = (filtroNome || '').toLowerCase();
|
|
||||||
const desc = (filtroDescricao || '').toLowerCase();
|
|
||||||
const okNome = !nome || (s.nome || '').toLowerCase().includes(nome);
|
|
||||||
const okDesc = !desc || (s.descricao || '').toLowerCase().includes(desc);
|
|
||||||
const okTipo = !filtroTipo || s.tipo === filtroTipo;
|
|
||||||
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;
|
let deletingId = $state<Id<'simbolos'> | null>(null);
|
||||||
let simboloToDelete: { id: Id<'simbolos'>; nome: string } | 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"
|
||||||
|
|||||||
2
packages/backend/convex/_generated/api.d.ts
vendored
2
packages/backend/convex/_generated/api.d.ts
vendored
@@ -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;
|
||||||
|
|||||||
59
packages/backend/convex/menu.ts
Normal file
59
packages/backend/convex/menu.ts
Normal 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 [];
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user