Files
sgse-app/apps/web/src/routes/(dashboard)/ti/painel-permissoes/+page.svelte

664 lines
21 KiB
Svelte

<script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import {
Home,
ShieldCheck,
Plus,
ArrowLeft,
CheckCircle,
XCircle,
Search,
X,
ChevronDown
} from 'lucide-svelte';
const client = useConvexClient();
// Carregar lista de roles e catálogo de recursos/ações
const rolesQuery = useQuery(api.roles.listar, {});
const catalogoQuery = useQuery(api.permissoesAcoes.listarRecursosEAcoes, {});
let salvando = $state(false);
let mensagem = $state<{ tipo: 'success' | 'error'; texto: string } | null>(null);
let busca = $state('');
let filtroRole = $state<Id<'roles'> | ''>('');
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);
// Controla quais recursos estão expandidos (mostrando as ações) por perfil
// Formato: { "roleId-recurso": true/false }
let recursosExpandidos: Record<string, boolean> = $state({});
// Cache de permissões por role
let permissoesPorRole: Record<string, Array<{ recurso: string; acoes: Array<string> }>> = $state(
{}
);
async function carregarPermissoesRole(roleId: Id<'roles'>) {
if (permissoesPorRole[roleId]) return;
const dados = await client.query(api.permissoesAcoes.listarPermissoesAcoesPorRole, { roleId });
permissoesPorRole[roleId] = dados;
}
function toggleRecurso(roleId: Id<'roles'>, recurso: string) {
const key = `${roleId}-${recurso}`;
recursosExpandidos[key] = !recursosExpandidos[key];
}
function isRecursoExpandido(roleId: Id<'roles'>, recurso: string) {
const key = `${roleId}-${recurso}`;
return recursosExpandidos[key] ?? false;
}
function mostrarMensagem(tipo: 'success' | 'error', texto: string) {
mensagem = { tipo, texto };
setTimeout(() => {
mensagem = null;
}, 3000);
}
const rolesFiltradas = $derived.by(() => {
if (!rolesQuery.data) return [];
let rs = rolesQuery.data; // Removed explicit type annotation
if (filtroRole) rs = rs.filter((r) => r._id === filtroRole); // Removed as any
if (busca.trim()) {
const b = busca.toLowerCase();
rs = rs.filter(
(r) => r.descricao.toLowerCase().includes(b) || r.nome.toLowerCase().includes(b)
);
}
return rs;
});
// Carregar permissões para todos os perfis filtrados quando necessário
$effect(() => {
if (rolesFiltradas && catalogoQuery.data) {
for (const roleRow of rolesFiltradas) {
if (roleRow.nivel > 1) {
carregarPermissoesRole(roleRow._id);
}
}
}
});
async function toggleAcao(roleId: Id<'roles'>, recurso: string, acao: string, conceder: boolean) {
try {
salvando = true;
await client.mutation(api.permissoesAcoes.atualizarPermissaoAcao, {
roleId,
recurso,
acao,
conceder
});
// Atualizar cache local
const atual = permissoesPorRole[roleId] || [];
const entry = atual.find((e) => e.recurso === recurso);
if (entry) {
entry.acoes = conceder
? [...entry.acoes.filter((valor) => valor !== acao), acao]
: entry.acoes.filter((valor) => valor !== acao);
} else if (conceder) {
permissoesPorRole[roleId] = [...atual, { recurso, acoes: [acao] }];
}
mostrarMensagem('success', 'Permissão atualizada com sucesso!');
} catch (error: unknown) {
// Changed to unknown
const message = error instanceof Error ? error.message : 'Erro ao atualizar permissão';
mostrarMensagem('error', message);
} finally {
salvando = false;
}
}
function isConcedida(roleId: Id<'roles'>, recurso: string, acao: string) {
const dados = permissoesPorRole[roleId];
const entry = dados?.find((e) => e.recurso === recurso);
return entry ? entry.acoes.includes(acao) : false;
}
const gerarIdentificador = (valor: string) =>
valor
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.replace(/_{2,}/g, '_');
const identificadorSugerido = $derived.by(() => gerarIdentificador(nomeNovoPerfil));
const 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;
});
const roleDuplicacaoSelecionada = $derived.by(() => {
if (!roleParaDuplicar || !rolesQuery.data) return null;
return rolesQuery.data.find((role) => role._id === roleParaDuplicar) ?? null;
});
$effect(() => {
if (roleParaDuplicar) {
carregarPermissoesRole(roleParaDuplicar);
}
});
const resumoPermissoesDuplicacao = $derived.by(() => {
if (!roleParaDuplicar) return null;
const permissoes = permissoesPorRole[roleParaDuplicar];
if (!permissoes) return null;
const totalRecursos = permissoes.length;
const totalAcoes = permissoes.reduce((acc, item) => acc + item.acoes.length, 0);
return { totalRecursos, totalAcoes };
});
function abrirModalNovoPerfil() {
nomeNovoPerfil = '';
descricaoNovoPerfil = '';
setorNovoPerfil = '';
nivelNovoPerfil = 3;
roleParaDuplicar = '';
modalNovoPerfilAberto = true;
}
function fecharModalNovoPerfil() {
modalNovoPerfilAberto = false;
}
async function criarNovoPerfil() {
if (!podeSalvarNovoPerfil) return;
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
});
if (resultado.sucesso) {
mostrarMensagem('success', 'Perfil criado com sucesso!');
modalNovoPerfilAberto = false;
} else {
const mapaErros: Record<string, string> = {
nome_ja_utilizado:
'Já existe um perfil com este identificador. Ajuste o nome e tente novamente.',
nome_invalido: 'Informe um nome válido com pelo menos 3 caracteres.',
sem_permissao: 'Você não possui permissão para criar novos perfis.',
role_origem_nao_encontrada: 'Não foi possível carregar o perfil escolhido como base.'
};
const mensagemErro =
mapaErros[resultado.erro] ?? 'Não foi possível criar o perfil. Tente novamente.';
mostrarMensagem('error', mensagemErro);
}
} catch (error: unknown) {
const mensagemErro =
error instanceof Error ? error.message : 'Erro inesperado ao criar o perfil.';
mostrarMensagem('error', mensagemErro);
} finally {
criandoNovoPerfil = false;
}
}
</script>
<ProtectedRoute allowedRoles={['ti_master', 'admin']} maxLevel={1}>
<!-- Breadcrumb -->
<div class="breadcrumbs mb-4 text-sm">
<ul>
<li>
<a href={resolve('/')} class="text-primary hover:text-primary-focus">
<Home class="h-4 w-4" strokeWidth={2} />
Dashboard
</a>
</li>
<li>
<a href={resolve('/ti')} class="text-primary hover:text-primary-focus">TI</a>
</li>
<li class="font-semibold">Gerenciar Perfis & Permissões</li>
</ul>
</div>
<!-- Header -->
<div class="mb-6">
<div class="mb-2 flex flex-wrap items-center gap-3">
<div class="bg-primary/10 rounded-xl p-3">
<ShieldCheck class="text-primary h-8 w-8" strokeWidth={2} />
</div>
<div class="flex-1">
<h1 class="text-base-content text-3xl font-bold">
Gerenciar Perfis & Permissões de Acesso
</h1>
<p class="text-base-content/60 mt-1">
Configure as permissões de acesso aos menus do sistema por função
</p>
</div>
<button class="btn btn-primary gap-2" onclick={abrirModalNovoPerfil}>
<Plus class="h-5 w-5" strokeWidth={2} />
Criar novo perfil
</button>
<button class="btn gap-2" onclick={() => goto(resolve('/ti'))}>
<ArrowLeft class="h-5 w-5" strokeWidth={2} />
Voltar
</button>
</div>
</div>
<!-- Alertas -->
{#if mensagem}
<div
class="alert mb-6 shadow-lg"
class:alert-success={mensagem.tipo === 'success'}
class:alert-error={mensagem.tipo === 'error'}
>
{#if mensagem.tipo === 'success'}
<CheckCircle class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
{:else}
<XCircle class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
{/if}
<span class="font-semibold">{mensagem.texto}</span>
</div>
{/if}
<!-- Filtros e Busca -->
<div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Busca por menu -->
<div class="form-control">
<label class="label" for="busca">
<span class="label-text font-semibold">Buscar Perfil</span>
</label>
<div class="relative">
<input
id="busca"
type="text"
placeholder="Digite o nome/descrição do perfil..."
class="input input-bordered w-full pr-10"
bind:value={busca}
/>
<Search class="text-base-content/40 absolute top-3.5 right-3 h-5 w-5" strokeWidth={2} />
</div>
</div>
<!-- Filtro por perfil -->
<div class="form-control">
<label class="label" for="filtroRole">
<span class="label-text font-semibold">Filtrar por Perfil</span>
</label>
<select id="filtroRole" class="select select-bordered w-full" bind:value={filtroRole}>
<option value="">Todos os perfis</option>
{#if rolesQuery.data}
{#each rolesQuery.data as roleRow (roleRow._id)}
<option value={roleRow._id}>
{roleRow.descricao} ({roleRow.nome})
</option>
{/each}
{/if}
</select>
</div>
</div>
{#if busca || filtroRole}
<div class="mt-2 flex items-center gap-2">
<span class="text-base-content/60 text-sm">Filtros ativos:</span>
{#if busca}
<div class="badge badge-primary gap-2">
Busca: {busca}
<button class="btn btn-xs" onclick={() => (busca = '')} aria-label="Limpar busca">
</button>
</div>
{/if}
{#if filtroRole}
<div class="badge badge-secondary gap-2">
Perfil filtrado
<button
class="btn btn-xs"
onclick={() => (filtroRole = '')}
aria-label="Limpar filtro"
>
</button>
</div>
{/if}
</div>
{/if}
</div>
</div>
<!-- Matriz de Permissões por Ação -->
{#if rolesQuery.isLoading || catalogoQuery.isLoading}
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if rolesQuery.error}
<div class="alert alert-error">
<XCircle class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
<span>Erro ao carregar perfis: {rolesQuery.error.message}</span>
</div>
{:else if rolesQuery.data && catalogoQuery.data}
{#if rolesFiltradas.length === 0}
<div class="card bg-base-100 shadow-xl">
<div class="card-body items-center text-center">
<Search class="text-base-content/30 h-16 w-16" strokeWidth={2} />
<h3 class="mt-4 text-xl font-bold">Nenhum resultado encontrado</h3>
<p class="text-base-content/60">
{busca
? `Não foram encontrados perfis com "${busca}"`
: 'Nenhum perfil corresponde aos filtros aplicados'}
</p>
<button
class="btn btn-primary btn-sm mt-4"
onclick={() => {
busca = '';
filtroRole = '';
}}
>
Limpar Filtros
</button>
</div>
</div>
{/if}
{#each rolesFiltradas as roleRow (roleRow._id)}
{@const roleId = roleRow._id}
<div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body">
<div class="mb-4 flex flex-wrap items-center gap-4">
<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}
<div class="badge badge-lg badge-success gap-1">
<CheckCircle class="h-4 w-4" strokeWidth={2} />
Acesso Total
</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>
{#if roleRow.nivel <= 1}
<div class="alert alert-success shadow-md">
<CheckCircle class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
<div>
<h3 class="font-bold">Perfil Administrativo</h3>
<div class="text-sm">
Este perfil possui acesso total ao sistema automaticamente, sem necessidade de
configuração manual.
</div>
</div>
</div>
{:else if catalogoQuery.data && catalogoQuery.data.length > 0}
<div class="space-y-2">
{#each catalogoQuery.data as item (item.recurso)}
{@const recursoExpandido = isRecursoExpandido(roleId, item.recurso)}
<div class="border-base-300 overflow-hidden rounded-lg border">
<!-- Cabeçalho do recurso (clicável) -->
<button
type="button"
class="bg-base-200 hover:bg-base-300 flex w-full items-center justify-between px-4 py-3 transition-colors"
onclick={() => toggleRecurso(roleId, item.recurso)}
disabled={salvando}
>
<span class="text-lg font-semibold">{item.recurso}</span>
<ChevronDown
class="h-5 w-5 transition-transform {recursoExpandido ? 'rotate-180' : ''}"
strokeWidth={2}
/>
</button>
<!-- Lista de ações (visível quando expandido) -->
{#if recursoExpandido}
<div class="bg-base-100 border-base-300 border-t px-4 py-3">
{#if item.acoes.length === 0}
<p class="text-base-content/60 text-sm">
Nenhuma permissão cadastrada para este recurso.
</p>
{:else}
<div class="space-y-2">
{#each item.acoes as acao (acao)}
<label
class="hover:bg-base-200 flex cursor-pointer items-center gap-3 rounded p-2 transition-colors"
>
<input
type="checkbox"
class="checkbox checkbox-primary"
checked={isConcedida(roleId, item.recurso, acao)}
disabled={salvando}
onchange={(e) =>
toggleAcao(roleId, item.recurso, acao, e.currentTarget.checked)}
/>
<span class="flex-1 font-medium">{acao}</span>
</label>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="alert alert-info mt-4">
<span class="font-semibold">
Nenhuma permissão cadastrada ainda. Use o botão Criar permissão para começar.
</span>
</div>
{/if}
</div>
</div>
{/each}
{/if}
{#if modalNovoPerfilAberto}
<dialog class="modal modal-open">
<div
class="modal-box bg-base-100 w-full max-w-4xl 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={fecharModalNovoPerfil}
aria-label="Fechar"
>
</button>
<div class="space-y-2 pr-10">
<h3 class="text-3xl font-black tracking-tight">Criar novo perfil de acesso</h3>
<p class="text-base-100/80 max-w-2xl text-sm md:text-base">
Defina as informações do perfil e, se desejar, reutilize as permissões de um perfil
existente para acelerar a configuração.
</p>
{#if identificadorSugerido}
<div class="text-base-100/80 flex flex-wrap items-center gap-2 text-xs">
<span
class="badge badge-outline badge-sm border-base-100/40 tracking-widest uppercase"
>
Identificador
</span>
<span class="font-mono text-sm">{identificadorSugerido}</span>
</div>
{/if}
</div>
</div>
<div class="space-y-6 px-8 py-6">
<div class="grid gap-6 md:grid-cols-2">
<div class="form-control md:col-span-2">
<label class="label" for="nome-novo-perfil">
<span class="label-text font-semibold">Nome do perfil *</span>
</label>
<input
id="nome-novo-perfil"
type="text"
class="input input-bordered input-primary"
placeholder="Ex.: Financeiro, RH, Supervisão de Campo"
bind:value={nomeNovoPerfil}
maxlength={60}
/>
</div>
<div class="form-control md:col-span-2">
<label class="label" for="descricao-novo-perfil">
<span class="label-text font-semibold">Descrição</span>
</label>
<textarea
id="descricao-novo-perfil"
class="textarea textarea-bordered textarea-primary min-h-[96px]"
placeholder="Explique como este perfil será utilizado e quais áreas ele atende."
bind:value={descricaoNovoPerfil}
maxlength={240}
></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>
</label>
<select
id="duplicar-novo-perfil"
class="select select-bordered"
bind:value={roleParaDuplicar}
>
<option value="">Iniciar perfil vazio</option>
{#if rolesQuery.data}
{#each rolesQuery.data as roleDuplicavel (roleDuplicavel._id)}
<option value={roleDuplicavel._id}>
{roleDuplicavel.descricao} nível {roleDuplicavel.nivel}
</option>
{/each}
{/if}
</select>
</div>
</div>
{#if roleDuplicacaoSelecionada}
<div class="border-base-200 bg-base-100/80 rounded-2xl border p-4">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<p class="text-base-content font-semibold">
Permissões a serem copiadas de <span class="font-bold"
>{roleDuplicacaoSelecionada.descricao}</span
>
</p>
<p class="text-base-content/70 text-sm">
As permissões atuais serão reaproveitadas automaticamente para o novo perfil.
</p>
</div>
{#if resumoPermissoesDuplicacao}
<div class="flex items-center gap-4 text-sm">
<span class="badge badge-outline badge-sm">
{resumoPermissoesDuplicacao.totalRecursos} recursos
</span>
<span class="badge badge-outline badge-sm">
{resumoPermissoesDuplicacao.totalAcoes} ações
</span>
</div>
{:else}
<span class="loading loading-dots loading-sm text-primary"></span>
{/if}
</div>
</div>
{/if}
<div class="flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-base-content/60 text-xs">
Campos marcados com * são obrigatórios. As alterações entram em vigor imediatamente
após a criação.
</p>
<div class="flex flex-wrap gap-3">
<button
type="button"
class="btn"
onclick={fecharModalNovoPerfil}
disabled={criandoNovoPerfil}
>
Cancelar
</button>
<button
type="button"
class="btn btn-primary"
disabled={!podeSalvarNovoPerfil}
onclick={criarNovoPerfil}
>
{#if criandoNovoPerfil}
<span class="loading loading-spinner"></span>
Criando perfil...
{:else}
Salvar perfil
{/if}
</button>
</div>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={fecharModalNovoPerfil} aria-label="Fechar modal">
fechar
</button>
</form>
</dialog>
{/if}
</ProtectedRoute>