refactor: Remove dedicated role management page and update authentication, roles, and permission handling across backend and frontend.

This commit is contained in:
2025-12-05 14:29:34 -03:00
parent c8d717b315
commit 69f32a342c
16 changed files with 358 additions and 958 deletions

View File

@@ -2,19 +2,16 @@
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useQuery } from 'convex-svelte';
import type { Snippet } from 'svelte';
import { onMount } from 'svelte';
const {
children,
requireAuth = true,
allowedRoles = [],
maxLevel = 3,
redirectTo = '/'
}: {
children: Snippet;
requireAuth?: boolean;
allowedRoles?: string[];
maxLevel?: number;
redirectTo?: string;
} = $props();
@@ -72,13 +69,6 @@
}
}
// Verificar nível
if (currentUser.data.role?.nivel && currentUser.data.role.nivel > maxLevel) {
const currentPath = window.location.pathname;
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
return;
}
// Se chegou aqui, permitir acesso
hasAccess = true;
isChecking = false;

View File

@@ -1,6 +1,6 @@
import type { Id } from '@sgse-app/backend/convex/betterAuth/_generated/dataModel';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
interface Usuario {
_id: string;
@@ -11,8 +11,7 @@ interface Usuario {
role: {
_id: string;
nome: string;
nivel: number;
setor?: string;
admin?: boolean;
};
primeiroAcesso: boolean;
avatar?: string;
@@ -56,7 +55,7 @@ class AuthStore {
}
get isAdmin() {
return this.state.usuario?.role.nivel === 0;
return this.state.usuario?.role.admin === true;
}
get isTI() {

View File

@@ -19,11 +19,16 @@
let modalNovoPerfilAberto = $state(false);
let nomeNovoPerfil = $state('');
let descricaoNovoPerfil = $state('');
let setorNovoPerfil = $state('');
let nivelNovoPerfil = $state(3);
let roleParaDuplicar = $state<Id<'roles'> | ''>('');
let criandoNovoPerfil = $state(false);
// Estado para modal de edição
let modalEditarPerfilAberto = $state(false);
let roleParaEditar = $state<Id<'roles'> | null>(null);
let descricaoEditarPerfil = $state('');
let editandoPerfil = $state(false);
let excluindoPerfil = $state(false);
// Controla quais recursos estão expandidos (mostrando as ações) por perfil
// Formato: { "roleId-recurso": true/false }
let recursosExpandidos: Record<string, boolean> = $state({});
@@ -74,13 +79,81 @@
$effect(() => {
if (rolesFiltradas && catalogoQuery.data) {
for (const roleRow of rolesFiltradas) {
if (roleRow.nivel > 1) {
if (roleRow.admin !== true) {
carregarPermissoesRole(roleRow._id);
}
}
}
});
function abrirModalEditar(role: { _id: Id<'roles'>; nome: string; descricao: string }) {
roleParaEditar = role._id;
descricaoEditarPerfil = role.descricao;
modalEditarPerfilAberto = true;
}
function fecharModalEditar() {
modalEditarPerfilAberto = false;
roleParaEditar = null;
}
async function editarPerfil() {
if (!roleParaEditar) return;
editandoPerfil = true;
try {
const resultado = await client.mutation(api.roles.editar, {
roleId: roleParaEditar,
descricao: descricaoEditarPerfil.trim()
});
if (resultado.sucesso) {
mostrarMensagem('success', 'Perfil atualizado com sucesso!');
fecharModalEditar();
} else {
const mapaErros: Record<string, string> = {
nome_ja_utilizado: 'Já existe um perfil com este identificador.',
nome_invalido: 'Informe um nome válido com pelo menos 3 caracteres.',
sem_permissao: 'Você não possui permissão para editar perfis.',
role_nao_encontrada: 'Perfil não encontrado.'
};
mostrarMensagem('error', mapaErros[resultado.erro] ?? resultado.erro);
}
} catch (error: unknown) {
const mensagemErro = error instanceof Error ? error.message : 'Erro ao editar perfil.';
mostrarMensagem('error', mensagemErro);
} finally {
editandoPerfil = false;
}
}
async function excluirPerfil(roleId: Id<'roles'>) {
if (!confirm('Tem certeza que deseja excluir este perfil? Esta ação não pode ser desfeita.')) {
return;
}
excluindoPerfil = true;
try {
const resultado = await client.mutation(api.roles.excluir, { roleId });
if (resultado.sucesso) {
mostrarMensagem('success', 'Perfil excluído com sucesso!');
} else {
const mapaErros: Record<string, string> = {
sem_permissao: 'Você não possui permissão para excluir perfis.',
role_nao_encontrada: 'Perfil não encontrado.',
role_possui_usuarios: 'Não é possível excluir um perfil que possui usuários vinculados.'
};
mostrarMensagem('error', mapaErros[resultado.erro] ?? resultado.erro);
}
} catch (error: unknown) {
const mensagemErro = error instanceof Error ? error.message : 'Erro ao excluir perfil.';
mostrarMensagem('error', mensagemErro);
} finally {
excluindoPerfil = false;
}
}
async function toggleAcao(roleId: Id<'roles'>, recurso: string, acao: string, conceder: boolean) {
try {
salvando = true;
@@ -130,9 +203,7 @@
let podeSalvarNovoPerfil = $derived.by(() => {
const nome = nomeNovoPerfil.trim();
const nivel = Number(nivelNovoPerfil);
const nivelValido = Number.isFinite(nivel) && nivel >= 0 && nivel <= 10;
return nome.length >= 3 && nivelValido && !criandoNovoPerfil;
return nome.length >= 3 && !criandoNovoPerfil;
});
let roleDuplicacaoSelecionada = $derived.by(() => {
@@ -158,8 +229,6 @@
function abrirModalNovoPerfil() {
nomeNovoPerfil = '';
descricaoNovoPerfil = '';
setorNovoPerfil = '';
nivelNovoPerfil = 3;
roleParaDuplicar = '';
modalNovoPerfilAberto = true;
}
@@ -173,16 +242,12 @@
const nome = nomeNovoPerfil.trim();
const descricao = descricaoNovoPerfil.trim();
const setor = setorNovoPerfil.trim();
const nivel = Math.min(Math.max(Math.round(Number(nivelNovoPerfil)), 0), 10);
criandoNovoPerfil = true;
try {
const resultado = await client.mutation(api.roles.criar, {
nome,
descricao,
nivel,
setor: setor.length > 0 ? setor : undefined,
copiarDeRoleId: roleParaDuplicar || undefined
});
@@ -211,7 +276,7 @@
}
</script>
<ProtectedRoute allowedRoles={['ti_master', 'admin']} maxLevel={1}>
<ProtectedRoute allowedRoles={['ti_master', 'admin']}>
<!-- Breadcrumb -->
<div class="breadcrumbs mb-4 text-sm">
<ul>
@@ -492,10 +557,8 @@
<div class="min-w-[200px] flex-1">
<div class="mb-2 flex items-center gap-3">
<h2 class="card-title text-2xl">{roleRow.descricao}</h2>
<div class="badge badge-lg badge-primary">
Nível {roleRow.nivel}
</div>
{#if roleRow.nivel <= 1}
{#if roleRow.admin}
<div class="badge badge-lg badge-error">Admin</div>
<div class="badge badge-lg badge-success gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -513,15 +576,63 @@
</svg>
Acesso Total
</div>
{:else}
<div class="badge badge-lg badge-info">Usuário</div>
{/if}
</div>
<p class="text-base-content/60 text-sm">
<span class="bg-base-200 rounded px-2 py-1 font-mono">{roleRow.nome}</span>
</p>
</div>
<div class="flex gap-2">
<button
type="button"
class="btn btn-sm btn-ghost"
onclick={() => abrirModalEditar(roleRow)}
disabled={salvando}
aria-label="Editar perfil"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
type="button"
class="btn btn-sm btn-ghost text-error"
onclick={() => excluirPerfil(roleRow._id)}
disabled={salvando || excluindoPerfil}
aria-label="Excluir perfil"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
{#if roleRow.nivel <= 1}
{#if roleRow.admin}
<div class="alert alert-success shadow-md">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -680,38 +791,6 @@
></textarea>
</div>
<div class="form-control">
<label class="label" for="setor-novo-perfil">
<span class="label-text font-semibold">Setor (opcional)</span>
</label>
<input
id="setor-novo-perfil"
type="text"
class="input input-bordered"
placeholder="Informe o setor ou departamento responsável"
bind:value={setorNovoPerfil}
maxlength={40}
/>
</div>
<div class="form-control">
<label class="label" for="nivel-novo-perfil">
<span class="label-text font-semibold">Nível de acesso (0 a 10)</span>
</label>
<input
id="nivel-novo-perfil"
type="number"
class="input input-bordered"
min={0}
max={10}
step={1}
bind:value={nivelNovoPerfil}
/>
<span class="text-base-content/60 mt-1 text-xs">
Níveis menores representam maior privilégio (ex.: 0 = administrativo).
</span>
</div>
<div class="form-control md:col-span-2">
<label class="label" for="duplicar-novo-perfil">
<span class="label-text font-semibold">Duplicar permissões de</span>
@@ -725,7 +804,8 @@
{#if rolesQuery.data}
{#each rolesQuery.data as roleDuplicavel (roleDuplicavel._id)}
<option value={roleDuplicavel._id}>
{roleDuplicavel.descricao} nível {roleDuplicavel.nivel}
{roleDuplicavel.descricao}
{roleDuplicavel.admin ? '(Admin)' : ''}
</option>
{/each}
{/if}
@@ -800,4 +880,66 @@
</form>
</dialog>
{/if}
{#if modalEditarPerfilAberto}
<dialog class="modal modal-open">
<div class="modal-box bg-base-100 w-full max-w-lg overflow-hidden rounded-2xl p-0 shadow-2xl">
<div
class="from-primary via-primary/85 to-secondary/80 text-base-100 relative bg-linear-to-r px-8 py-6"
>
<button
type="button"
class="btn btn-circle btn-ghost btn-sm text-base-100/80 hover:text-base-100 absolute top-4 right-4"
onclick={fecharModalEditar}
aria-label="Fechar"
>
</button>
<div class="space-y-2 pr-10">
<h3 class="text-2xl font-black tracking-tight">Editar perfil de acesso</h3>
<p class="text-base-100/80 text-sm">Altere as informações do perfil selecionado.</p>
</div>
</div>
<div class="space-y-6 px-8 py-6">
<div class="form-control">
<label class="label" for="descricao-editar-perfil">
<span class="label-text font-semibold">Nome do perfil *</span>
</label>
<input
id="descricao-editar-perfil"
type="text"
class="input input-bordered input-primary"
placeholder="Ex.: Financeiro, RH, Supervisão de Campo"
bind:value={descricaoEditarPerfil}
maxlength={100}
/>
</div>
<div class="flex flex-wrap justify-end gap-3">
<button type="button" class="btn" onclick={fecharModalEditar} disabled={editandoPerfil}>
Cancelar
</button>
<button
type="button"
class="btn btn-primary"
disabled={editandoPerfil || descricaoEditarPerfil.trim().length < 3}
onclick={editarPerfil}
>
{#if editandoPerfil}
<span class="loading loading-spinner"></span>
Salvando...
{:else}
Salvar alterações
{/if}
</button>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={fecharModalEditar} aria-label="Fechar modal">
fechar
</button>
</form>
</dialog>
{/if}
</ProtectedRoute>

View File

@@ -1,786 +0,0 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { AlertTriangle, Building2, Info, Shield, Users } from 'lucide-svelte';
import { resolve } from '$app/paths';
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
import StatsCard from '$lib/components/ti/StatsCard.svelte';
type Role = {
_id: Id<'roles'>;
_creationTime: number;
nome: string;
descricao: string;
nivel: number;
setor?: string;
};
const client = useConvexClient();
const rolesQuery = useQuery(api.roles.listar, {});
let roles = $derived(rolesQuery?.data ?? []);
let carregando = $derived(rolesQuery === undefined);
let busca = $state('');
let filtroSetor = $state('');
let filtroNivel = $state<number | ''>('');
let roleSelecionada = $state<Role | null>(null);
let modalDetalhesAberto = $state(false);
let setoresDisponiveis = $derived.by(() => {
const setores = new Set<string>();
roles.forEach((r) => {
if (r.setor) setores.add(r.setor);
});
return Array.from(setores).sort();
});
// Estatísticas
let stats = $derived.by(() => {
if (carregando) return null;
const nivelMaximo = roles.filter((r) => r.nivel === 0).length;
const nivelAdministrativo = roles.filter((r) => r.nivel === 1).length;
const niveisLegado = roles.filter((r) => r.nivel > 1).length;
return {
total: roles.length,
nivelMaximo,
nivelAdministrativo,
niveisLegado,
comSetor: roles.filter((r) => r.setor).length
};
});
let rolesFiltradas = $derived.by(() => {
let resultado = roles;
// Filtro por busca (nome ou descrição)
if (busca.trim()) {
const buscaLower = busca.toLowerCase();
resultado = resultado.filter(
(r) =>
r.nome.toLowerCase().includes(buscaLower) ||
r.descricao.toLowerCase().includes(buscaLower)
);
}
// Filtro por setor
if (filtroSetor) {
resultado = resultado.filter((r) => r.setor === filtroSetor);
}
// Filtro por nível
if (filtroNivel !== '') {
if (filtroNivel === 0 || filtroNivel === 1) {
resultado = resultado.filter((r) => r.nivel === filtroNivel);
} else {
// Qualquer outro valor é considerado legado
resultado = resultado.filter((r) => r.nivel > 1);
}
}
return resultado.sort((a, b) => {
// Ordenar por nível primeiro (menor nível = maior privilégio)
if (a.nivel !== b.nivel) return a.nivel - b.nivel;
// Depois por nome
return a.nome.localeCompare(b.nome);
});
});
function obterCorNivel(nivel: number): string {
if (nivel === 0) return 'badge-error';
if (nivel === 1) return 'badge-warning';
// Níveis > 1 são considerados legado
return 'badge-ghost';
}
function obterTextoNivel(nivel: number): string {
if (nivel === 0) return 'Máximo';
if (nivel === 1) return 'Administrativo';
return `Legado (${nivel})`;
}
function obterCorCardNivel(nivel: number): string {
if (nivel === 0) return 'border-l-4 border-error';
if (nivel === 1) return 'border-l-4 border-warning';
return 'border-l-4 border-base-300';
}
function abrirDetalhes(role: Role) {
roleSelecionada = role;
modalDetalhesAberto = true;
}
function fecharDetalhes() {
modalDetalhesAberto = false;
roleSelecionada = null;
}
function formatarData(timestamp: number): string {
try {
return format(new Date(timestamp), 'dd/MM/yyyy HH:mm', { locale: ptBR });
} catch {
return 'Data inválida';
}
}
function limparFiltros() {
busca = '';
filtroSetor = '';
filtroNivel = '';
}
let temFiltrosAtivos = $derived(
busca.trim() !== '' || filtroSetor !== '' || filtroNivel !== ''
);
</script>
<ProtectedRoute allowedRoles={['ti_master', 'admin', 'ti_usuario']} maxLevel={1}>
<div class="container mx-auto max-w-7xl px-4 py-6">
<!-- Header -->
<div class="mb-8 flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="bg-primary/10 rounded-xl p-3">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
</div>
<div>
<h1 class="text-base-content text-3xl font-bold">Gestão de Perfis</h1>
<p class="text-base-content/60 mt-1">
Visualize e gerencie os perfis de acesso do sistema
</p>
</div>
</div>
</div>
<!-- Estatísticas -->
{#if stats}
<div class="mb-8 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard title="Total de Perfis" value={stats.total} Icon={Users} color="primary" />
<StatsCard
title="Nível Máximo (0)"
value={stats.nivelMaximo}
description="Acesso total ao sistema"
Icon={Shield}
color="error"
/>
<StatsCard
title="Nível Administrativo (1)"
value={stats.nivelAdministrativo}
description="Perfis administrativos com acesso total"
Icon={AlertTriangle}
color="warning"
/>
<StatsCard
title="Perfis com Setor"
value={stats.comSetor}
description={stats.total > 0
? ((stats.comSetor / stats.total) * 100).toFixed(0) + '% do total'
: '0%'}
Icon={Building2}
color="secondary"
/>
</div>
{#if stats.niveisLegado > 0}
<div class="alert alert-warning mb-6">
<Info class="h-5 w-5" />
<div>
<h3 class="font-bold">Perfis com níveis legados</h3>
<p class="text-sm">
Existem {stats.niveisLegado} perfis com nível acima de 1. Esses perfis continuarão sendo
tratados como nível 1 (administrativo) após a migração.
</p>
</div>
</div>
{/if}
{/if}
<!-- Filtros -->
{#if !carregando && roles.length > 0}
<div class="card bg-base-100 border-base-300 mb-6 border shadow-xl">
<div class="card-body">
<div class="mb-4 flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
<h2 class="card-title text-lg">Filtros de Busca</h2>
</div>
{#if temFiltrosAtivos}
<button
type="button"
class="btn btn-sm btn-outline btn-error"
onclick={limparFiltros}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
Limpar Filtros
</button>
{/if}
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<!-- Busca -->
<div class="form-control">
<label class="label" for="busca">
<span class="label-text font-medium">Buscar</span>
</label>
<div class="relative">
<input
id="busca"
type="text"
bind:value={busca}
placeholder="Buscar por nome ou descrição..."
class="input input-bordered w-full pl-10"
/>
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/40 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2 transform"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
</div>
<!-- Setor -->
<div class="form-control">
<label class="label" for="filtro-setor">
<span class="label-text font-medium">Setor</span>
</label>
<select id="filtro-setor" bind:value={filtroSetor} class="select select-bordered">
<option value="">Todos os setores</option>
{#each setoresDisponiveis as setor}
<option value={setor}>{setor}</option>
{/each}
</select>
</div>
<!-- Nível -->
<div class="form-control">
<label class="label" for="filtro-nivel">
<span class="label-text font-medium">Nível de Acesso</span>
</label>
<select id="filtro-nivel" bind:value={filtroNivel} class="select select-bordered">
<option value="">Todos os níveis</option>
<option value={0}>Máximo (0)</option>
<option value={1}>Alto (1)</option>
<option value={2}>Médio (2)</option>
<option value={3}>Baixo (3+)</option>
</select>
</div>
</div>
<div class="mt-4 flex items-center justify-between">
<div class="text-base-content/60 text-sm">
<span class="text-base-content font-medium">{rolesFiltradas.length}</span>
de
<span class="text-base-content font-medium">{roles.length}</span>
perfil(is)
{#if temFiltrosAtivos}
<span class="badge badge-primary badge-sm ml-2">Filtrado</span>
{/if}
</div>
</div>
</div>
</div>
{/if}
<!-- Lista de Perfis -->
{#if carregando}
<div class="flex items-center justify-center py-20">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if roles.length === 0}
<div class="flex flex-col items-center justify-center py-16 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/30 h-16 w-16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<h3 class="mt-4 text-xl font-semibold">Nenhum perfil encontrado</h3>
<p class="text-base-content/60 mt-2">Não há perfis cadastrados no sistema.</p>
</div>
{:else if rolesFiltradas.length === 0}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex flex-col items-center justify-center py-16 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/30 mb-4 h-16 w-16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h3 class="mt-4 text-xl font-semibold">Nenhum perfil encontrado</h3>
<p class="text-base-content/60 mt-2">
Nenhum perfil corresponde aos filtros aplicados.
</p>
{#if temFiltrosAtivos}
<button class="btn btn-primary btn-sm mt-4" onclick={limparFiltros}>
Limpar Filtros
</button>
{/if}
</div>
</div>
</div>
{:else}
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each rolesFiltradas as role}
<div
class="card bg-base-100 border-base-300 cursor-pointer border shadow-xl transition-all duration-300 hover:shadow-2xl {obterCorCardNivel(
role.nivel
)} hover:scale-[1.02]"
onclick={() => abrirDetalhes(role)}
>
<div class="card-body">
<div class="mb-4 flex items-start justify-between">
<div class="flex-1">
<h2 class="card-title mb-1 text-lg">{role.descricao}</h2>
<div class="badge {obterCorNivel(role.nivel)} badge-sm">
{obterTextoNivel(role.nivel)}
</div>
</div>
<div class="bg-base-200 rounded-lg p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</div>
</div>
<div class="space-y-3 text-sm">
<div class="bg-base-200 flex items-center gap-2 rounded-lg p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/40 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
<span class="text-base-content/60 font-medium">Nome técnico:</span>
<code class="bg-base-100 rounded px-2 py-1 font-mono text-xs">{role.nome}</code>
</div>
{#if role.setor}
<div class="bg-base-200 flex items-center gap-2 rounded-lg p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/40 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<span class="text-base-content/60 font-medium">Setor:</span>
<span class="font-medium">{role.setor}</span>
</div>
{/if}
<div class="bg-base-200 flex items-center gap-2 rounded-lg p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/40 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
<span class="text-base-content/60 font-medium">Nível:</span>
<span class="text-lg font-bold">{role.nivel}</span>
</div>
</div>
<div class="card-actions border-base-300 mt-4 justify-end border-t pt-4">
<button
class="btn btn-sm btn-primary btn-outline"
onclick={(e) => {
e.stopPropagation();
abrirDetalhes(role);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-1 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
Ver Detalhes
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Modal Detalhes -->
{#if modalDetalhesAberto && roleSelecionada}
<div class="modal modal-open">
<div class="modal-box max-w-3xl">
<div class="mb-6 flex items-center justify-between">
<h3 class="text-2xl font-bold">Detalhes do Perfil</h3>
<button type="button" class="btn btn-sm btn-circle btn-ghost" onclick={fecharDetalhes}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="space-y-6">
<!-- Header do Perfil -->
<div class="card from-primary/10 to-secondary/10 border-primary/20 border bg-linear-to-r">
<div class="card-body">
<div class="flex items-start justify-between">
<div class="flex-1">
<h2 class="mb-2 text-2xl font-bold">
{roleSelecionada.descricao}
</h2>
<div class="flex items-center gap-3">
<div class="badge {obterCorNivel(roleSelecionada.nivel)} badge-lg">
{obterTextoNivel(roleSelecionada.nivel)}
</div>
<span class="text-base-content/60 text-sm">Nível {roleSelecionada.nivel}</span>
</div>
</div>
<div class="bg-base-100 rounded-lg p-3 shadow-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</div>
</div>
</div>
</div>
<!-- Informações Principais -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="card bg-base-100 border-base-300 border">
<div class="card-body">
<label class="label">
<span class="label-text flex items-center gap-2 font-semibold">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
Nome Técnico
</span>
</label>
<code class="bg-base-200 mt-2 block rounded-lg px-4 py-3 font-mono text-sm"
>{roleSelecionada.nome}</code
>
</div>
</div>
<div class="card bg-base-100 border-base-300 border">
<div class="card-body">
<label class="label">
<span class="label-text flex items-center gap-2 font-semibold">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
Setor
</span>
</label>
<p class="mt-2 text-lg font-medium">
{#if roleSelecionada.setor}
{roleSelecionada.setor}
{:else}
<span class="text-base-content/40 italic">Não especificado</span>
{/if}
</p>
</div>
</div>
</div>
<!-- Nível de Acesso -->
<div class="card bg-base-100 border-base-300 border">
<div class="card-body">
<label class="label">
<span class="label-text flex items-center gap-2 font-semibold">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
Nível de Acesso
</span>
</label>
<div class="mt-4">
<div class="mb-3 flex items-center gap-4">
<span class="text-4xl font-bold">{roleSelecionada.nivel}</span>
<div class="badge {obterCorNivel(roleSelecionada.nivel)} badge-lg">
{obterTextoNivel(roleSelecionada.nivel)}
</div>
</div>
<div class="alert alert-info">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span class="text-sm">
{roleSelecionada.nivel === 0 &&
'Acesso total irrestrito ao sistema. Pode realizar todas as operações sem restrições.'}
{roleSelecionada.nivel === 1 &&
'Acesso alto com algumas restrições. Pode realizar a maioria das operações administrativas.'}
{roleSelecionada.nivel === 2 &&
'Acesso médio com permissões configuráveis. Pode realizar operações padrão do sistema.'}
{roleSelecionada.nivel >= 3 &&
'Acesso limitado com permissões específicas. Operações restritas conforme configuração.'}
</span>
</div>
</div>
</div>
</div>
<!-- Data de Criação -->
<div class="card bg-base-100 border-base-300 border">
<div class="card-body">
<label class="label">
<span class="label-text flex items-center gap-2 font-semibold">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
Data de Criação
</span>
</label>
<p class="mt-2 text-lg font-medium">
{formatarData(roleSelecionada._creationTime)}
</p>
</div>
</div>
<!-- Informação sobre Permissões -->
<div class="alert alert-info">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<div>
<h4 class="mb-1 font-semibold">Configuração de Permissões</h4>
<p class="text-sm">
Para configurar permissões específicas deste perfil, acesse o <a
href={resolve('/ti/painel-permissoes')}
class="link link-primary font-semibold">Painel de Permissões</a
>.
</p>
</div>
</div>
</div>
<div class="modal-action mt-6">
<button type="button" class="btn" onclick={fecharDetalhes}> Fechar </button>
<a href={resolve('/ti/painel-permissoes')} class="btn btn-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
Configurar Permissões
</a>
</div>
</div>
<div class="modal-backdrop" onclick={fecharDetalhes}></div>
</div>
{/if}
</ProtectedRoute>

View File

@@ -21,13 +21,9 @@
role: {
_id: Id<'roles'>;
_creationTime?: number;
criadoPor?: Id<'usuarios'>;
customizado?: boolean;
descricao: string;
editavel?: boolean;
nome: string;
nivel: number;
setor?: string;
admin?: boolean;
erro?: boolean;
};
funcionario?: {
@@ -278,7 +274,7 @@
}
</script>
<ProtectedRoute allowedRoles={['ti_master', 'admin', 'ti_usuario']} maxLevel={1}>
<ProtectedRoute allowedRoles={['ti_master', 'admin', 'ti_usuario']}>
<main class="container mx-auto max-w-7xl px-4 py-6">
<!-- Breadcrumb -->
<div class="breadcrumbs mb-4 text-sm">

View File

@@ -16,9 +16,8 @@
type RoleUsuario = {
_id: Id<'roles'>;
nome: string;
nivel: number;
admin?: boolean;
descricao: string;
setor?: string;
erro?: boolean;
};
@@ -265,11 +264,6 @@
resultado = resultado.filter((u) => u.matricula.toLowerCase().includes(buscaMatricula));
}
// Filtro por setor
if (filtroSetor) {
resultado = resultado.filter((u) => u.role.setor === filtroSetor);
}
// Filtro por status
if (filtroStatus !== 'todos') {
if (filtroStatus === 'ativo') {
@@ -326,7 +320,6 @@
function limparFiltros() {
filtroNome = '';
filtroMatricula = '';
filtroSetor = '';
filtroStatus = 'todos';
filtroDataCriacaoInicio = '';
filtroDataCriacaoFim = '';
@@ -535,7 +528,7 @@
}
</script>
<ProtectedRoute allowedRoles={['ti_master', 'admin', 'ti_usuario']} maxLevel={1}>
<ProtectedRoute allowedRoles={['ti_master', 'admin', 'ti_usuario']}>
<div class="container mx-auto max-w-7xl px-4 py-6">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">