- Introduced a modal for managing user profiles, allowing for the creation and editing of profiles with improved state management. - Updated the role filtering logic to enhance type safety and readability. - Refactored UI components for better user experience, including improved button states and loading indicators. - Removed outdated code related to permissions and streamlined the overall structure for maintainability.
845 lines
28 KiB
Svelte
845 lines
28 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 type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
|
import { authStore } from "$lib/stores/auth.svelte";
|
|
type RoleRow = {
|
|
_id: Id<"roles">;
|
|
_creationTime: number;
|
|
nome: string;
|
|
descricao: string;
|
|
nivel: number;
|
|
setor?: string;
|
|
customizado: boolean;
|
|
editavel?: boolean;
|
|
criadoPor?: Id<"usuarios">;
|
|
};
|
|
|
|
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("");
|
|
// Controla quais recursos estão expandidos (mostrando as ações) por perfil
|
|
// Formato: { "roleId-recurso": true/false }
|
|
let recursosExpandidos: Record<string, boolean> = $state({});
|
|
|
|
// Gerenciamento de Perfis
|
|
let modalGerenciarPerfisAberto = $state(false);
|
|
let perfilSendoEditado = $state<RoleRow | null>(null);
|
|
let nomeNovoPerfil = $state("");
|
|
let descricaoNovoPerfil = $state("");
|
|
let nivelNovoPerfil = $state(3);
|
|
let processando = $state(false);
|
|
|
|
// 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) {
|
|
const set = new Set(entry.acoes);
|
|
if (conceder) set.add(acao);
|
|
else set.delete(acao);
|
|
entry.acoes = Array.from(set);
|
|
} else {
|
|
permissoesPorRole[roleId] = [
|
|
...atual,
|
|
{ recurso, acoes: conceder ? [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;
|
|
}
|
|
|
|
function abrirModalCriarPerfil() {
|
|
nomeNovoPerfil = "";
|
|
descricaoNovoPerfil = "";
|
|
nivelNovoPerfil = 3; // Default to a common level
|
|
perfilSendoEditado = null;
|
|
modalGerenciarPerfisAberto = true;
|
|
}
|
|
|
|
function prepararEdicaoPerfil(role: RoleRow) {
|
|
perfilSendoEditado = role;
|
|
nomeNovoPerfil = role.nome;
|
|
descricaoNovoPerfil = role.descricao;
|
|
nivelNovoPerfil = role.nivel;
|
|
modalGerenciarPerfisAberto = true;
|
|
}
|
|
|
|
function fecharModalGerenciarPerfis() {
|
|
modalGerenciarPerfisAberto = false;
|
|
perfilSendoEditado = null;
|
|
}
|
|
|
|
async function criarNovoPerfil() {
|
|
if (!nomeNovoPerfil.trim()) return;
|
|
|
|
processando = true;
|
|
try {
|
|
const result = await client.mutation(api.roles.criar, {
|
|
nome: nomeNovoPerfil.trim(),
|
|
descricao: descricaoNovoPerfil.trim(),
|
|
nivel: nivelNovoPerfil,
|
|
customizado: true,
|
|
});
|
|
|
|
if (result.sucesso) {
|
|
mostrarMensagem("success", "Perfil criado com sucesso!");
|
|
nomeNovoPerfil = "";
|
|
descricaoNovoPerfil = "";
|
|
nivelNovoPerfil = 3;
|
|
fecharModalGerenciarPerfis();
|
|
if (rolesQuery.refetch) { // Verificação para garantir que refetch existe
|
|
rolesQuery.refetch(); // Atualiza a lista de perfis
|
|
}
|
|
} else {
|
|
mostrarMensagem("error", `Erro ao criar perfil: ${result.erro}`);
|
|
}
|
|
} catch (error: unknown) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
mostrarMensagem("error", `Erro ao criar perfil: ${message}`);
|
|
} finally {
|
|
processando = false;
|
|
}
|
|
}
|
|
|
|
async function editarPerfil() {
|
|
if (!perfilSendoEditado || !nomeNovoPerfil.trim()) return;
|
|
|
|
processando = true;
|
|
try {
|
|
const result = await client.mutation(api.roles.atualizar, {
|
|
roleId: perfilSendoEditado._id,
|
|
nome: nomeNovoPerfil.trim(),
|
|
descricao: descricaoNovoPerfil.trim(),
|
|
nivel: nivelNovoPerfil,
|
|
setor: perfilSendoEditado.setor, // Manter setor existente
|
|
});
|
|
|
|
if (result.sucesso) {
|
|
mostrarMensagem("success", "Perfil atualizado com sucesso!");
|
|
fecharModalGerenciarPerfis();
|
|
if (rolesQuery.refetch) { // Verificação para garantir que refetch existe
|
|
rolesQuery.refetch(); // Atualiza a lista de perfis
|
|
}
|
|
} else {
|
|
mostrarMensagem("error", `Erro ao atualizar perfil: ${result.erro}`);
|
|
}
|
|
} catch (error: unknown) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
mostrarMensagem("error", `Erro ao atualizar perfil: ${message}`);
|
|
} finally {
|
|
processando = false;
|
|
}
|
|
}
|
|
|
|
</script>
|
|
|
|
<ProtectedRoute allowedRoles={["ti_master", "admin"]} maxLevel={1}>
|
|
<!-- Breadcrumb -->
|
|
<div class="text-sm breadcrumbs mb-4">
|
|
<ul>
|
|
<li>
|
|
<a href="/" class="text-primary hover:text-primary-focus">
|
|
<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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
|
/>
|
|
</svg>
|
|
Dashboard
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a href="/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="flex items-center gap-3 mb-2">
|
|
<div class="p-3 bg-primary/10 rounded-xl">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-8 w-8 text-primary"
|
|
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>
|
|
</div>
|
|
<div class="flex-1">
|
|
<h1 class="text-3xl font-bold text-base-content">
|
|
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={abrirModalCriarPerfil}
|
|
>
|
|
<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="M12 4v16m8-8H4"
|
|
/>
|
|
</svg>
|
|
Criar Novo Perfil
|
|
</button>
|
|
<button class="btn btn-ghost gap-2" onclick={() => goto("/ti")}>
|
|
<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="M10 19l-7-7m0 0l7-7m-7 7h18"
|
|
/>
|
|
</svg>
|
|
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"}
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="stroke-current shrink-0 h-6 w-6"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
{:else}
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="stroke-current shrink-0 h-6 w-6"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
{/if}
|
|
<span class="font-semibold">{mensagem.texto}</span>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Filtros e Busca -->
|
|
<div class="card bg-base-100 shadow-xl mb-6">
|
|
<div class="card-body">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<!-- 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}
|
|
/>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-5 w-5 absolute right-3 top-3.5 text-base-content/40"
|
|
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>
|
|
|
|
<!-- 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}
|
|
<option value={roleRow._id}>
|
|
{roleRow.descricao} ({roleRow.nome})
|
|
</option>
|
|
{/each}
|
|
{/if}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{#if busca || filtroRole}
|
|
<div class="flex items-center gap-2 mt-2">
|
|
<span class="text-sm text-base-content/60">Filtros ativos:</span>
|
|
{#if busca}
|
|
<div class="badge badge-primary gap-2">
|
|
Busca: {busca}
|
|
<button
|
|
class="btn btn-ghost 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-ghost btn-xs"
|
|
onclick={() => (filtroRole = "")}
|
|
aria-label="Limpar filtro"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Informações sobre o sistema de permissões -->
|
|
<div class="alert alert-info mb-6 shadow-lg">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="stroke-current shrink-0 h-6 w-6"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<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"
|
|
/>
|
|
</svg>
|
|
<div>
|
|
<h3 class="font-bold text-lg">Como funciona o sistema de permissões:</h3>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
|
|
<div>
|
|
<h4 class="font-semibold text-sm">Tipos de Permissão:</h4>
|
|
<ul class="text-sm mt-1 space-y-1">
|
|
<li>
|
|
• <strong>Acessar:</strong> Visualizar menu e acessar página
|
|
</li>
|
|
<li>• <strong>Consultar:</strong> Ver dados (requer "Acessar")</li>
|
|
<li>
|
|
• <strong>Gravar:</strong> Criar/editar/excluir (requer "Consultar")
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<div>
|
|
<h4 class="font-semibold text-sm">Perfis Especiais:</h4>
|
|
<ul class="text-sm mt-1 space-y-1">
|
|
<li>• <strong>Admin e TI:</strong> Acesso total automático</li>
|
|
<li>• <strong>Dashboard:</strong> Público para todos</li>
|
|
<li>
|
|
• <strong>Perfil Customizado:</strong> Permissões personalizadas
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Matriz de Permissões por Ação -->
|
|
{#if rolesQuery.isLoading || catalogoQuery.isLoading}
|
|
<div class="flex justify-center items-center py-12">
|
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
|
</div>
|
|
{:else if rolesQuery.error}
|
|
<div class="alert alert-error">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="stroke-current shrink-0 h-6 w-6"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
<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">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-16 w-16 text-base-content/30"
|
|
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>
|
|
<h3 class="text-xl font-bold mt-4">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}
|
|
{@const roleId = roleRow._id}
|
|
<div class="card bg-base-100 shadow-xl mb-6">
|
|
<div class="card-body">
|
|
<div class="flex items-center justify-between mb-4 flex-wrap gap-4">
|
|
<div class="flex-1 min-w-[200px]">
|
|
<div class="flex items-center gap-3 mb-2">
|
|
<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">
|
|
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
Acesso Total
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<p class="text-sm text-base-content/60">
|
|
<span class="font-mono bg-base-200 px-2 py-1 rounded"
|
|
>{roleRow.nome}</span
|
|
>
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
class="btn btn-outline btn-sm"
|
|
onclick={() => prepararEdicaoPerfil(roleRow)}
|
|
>
|
|
Editar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{#if roleRow.nivel <= 1}
|
|
<div class="alert alert-success shadow-md">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="stroke-current shrink-0 h-6 w-6"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
<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}
|
|
<div class="space-y-2">
|
|
{#each catalogoQuery.data as item}
|
|
{@const recursoExpandido = isRecursoExpandido(
|
|
roleId,
|
|
item.recurso
|
|
)}
|
|
<div class="border border-base-300 rounded-lg overflow-hidden">
|
|
<!-- Cabeçalho do recurso (clicável) -->
|
|
<button
|
|
type="button"
|
|
class="w-full px-4 py-3 bg-base-200 hover:bg-base-300 transition-colors flex items-center justify-between"
|
|
onclick={() => toggleRecurso(roleId, item.recurso)}
|
|
disabled={salvando}
|
|
>
|
|
<span class="font-semibold text-lg">{item.recurso}</span>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-5 w-5 transition-transform"
|
|
class:rotate-180={recursoExpandido}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M19 9l-7 7-7-7"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Lista de ações (visível quando expandido) -->
|
|
{#if recursoExpandido}
|
|
<div class="px-4 py-3 bg-base-100 border-t border-base-300">
|
|
<div class="space-y-2">
|
|
{#each ["ver", "listar", "criar", "editar", "excluir"] as acao}
|
|
<label
|
|
class="flex items-center gap-3 cursor-pointer hover:bg-base-200 p-2 rounded 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 capitalize font-medium"
|
|
>{acao}</span
|
|
>
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
|
|
<!-- Modal Gerenciar Perfis -->
|
|
{#if modalGerenciarPerfisAberto}
|
|
<dialog class="modal modal-open">
|
|
<div
|
|
class="modal-box max-w-4xl w-full overflow-hidden border border-base-200/60 bg-base-200/40 p-0 shadow-2xl"
|
|
>
|
|
<div
|
|
class="relative bg-gradient-to-r from-primary via-primary/90 to-secondary/80 px-8 py-6 text-base-100"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="btn btn-sm btn-circle btn-ghost absolute right-4 top-4 text-base-100/80 hover:text-base-100"
|
|
onclick={fecharModalGerenciarPerfis}
|
|
aria-label="Fechar modal"
|
|
>
|
|
✕
|
|
</button>
|
|
|
|
<h3 class="text-3xl font-black tracking-tight">Gerenciar Perfis de Acesso</h3>
|
|
<p class="mt-2 max-w-2xl text-sm text-base-100/80 md:text-base">
|
|
{perfilSendoEditado
|
|
? "Atualize as informações do perfil selecionado para manter a governança de acesso alinhada com as diretrizes do sistema."
|
|
: "Crie um novo perfil de acesso definindo nome, descrição e nível hierárquico conforme os padrões adotados pela Secretaria."}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="bg-base-100 px-8 py-6">
|
|
<section
|
|
class="space-y-6 rounded-2xl border border-base-200/60 bg-base-100 p-6 shadow-sm"
|
|
>
|
|
<div
|
|
class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"
|
|
>
|
|
<div class="space-y-1">
|
|
<h4 class="text-xl font-semibold text-base-content">
|
|
{perfilSendoEditado ? "Editar Perfil" : "Criar Novo Perfil"}
|
|
</h4>
|
|
<p class="text-sm text-base-content/70">
|
|
{perfilSendoEditado
|
|
? "Os campos bloqueados indicam atributos padronizados do sistema. Ajuste apenas o que estiver disponível."
|
|
: "Preencha as informações com atenção para garantir que o novo perfil siga o padrão institucional."}
|
|
</p>
|
|
</div>
|
|
|
|
{#if perfilSendoEditado}
|
|
<span
|
|
class="badge badge-outline badge-primary badge-lg self-start flex flex-col items-center gap-1 px-5 py-3 text-center"
|
|
>
|
|
<span class="text-[11px] font-semibold uppercase tracking-[0.32em] text-primary">
|
|
Nível atual
|
|
</span>
|
|
<span class="text-2xl font-bold leading-none text-primary">
|
|
{perfilSendoEditado.nivel}
|
|
</span>
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="grid gap-6 md:grid-cols-2">
|
|
<div class="form-control md:col-span-2">
|
|
<label class="label" for="nome-perfil-input">
|
|
<span class="label-text">Nome do Perfil *</span>
|
|
</label>
|
|
<input
|
|
id="nome-perfil-input"
|
|
type="text"
|
|
bind:value={nomeNovoPerfil}
|
|
class="input input-bordered input-primary"
|
|
placeholder="Ex: RH, Financeiro, Gestor"
|
|
disabled={perfilSendoEditado !== null && !perfilSendoEditado.customizado}
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-control md:col-span-2">
|
|
<label class="label" for="descricao-perfil-input">
|
|
<span class="label-text">Descrição</span>
|
|
</label>
|
|
<textarea
|
|
id="descricao-perfil-input"
|
|
bind:value={descricaoNovoPerfil}
|
|
class="textarea textarea-bordered textarea-primary min-h-[120px]"
|
|
placeholder="Breve descrição das responsabilidades e limites de atuação deste perfil."
|
|
></textarea>
|
|
</div>
|
|
|
|
<div class="form-control max-w-xs">
|
|
<label class="label" for="nivel-perfil-input">
|
|
<span class="label-text">Nível de Acesso (0-5) *</span>
|
|
</label>
|
|
<input
|
|
id="nivel-perfil-input"
|
|
type="number"
|
|
bind:value={nivelNovoPerfil}
|
|
min="0"
|
|
max="5"
|
|
class="input input-bordered input-secondary"
|
|
disabled={perfilSendoEditado !== null && !perfilSendoEditado.customizado}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div
|
|
class="mt-8 flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between"
|
|
>
|
|
<p class="text-sm text-base-content/60">
|
|
Campos marcados com * são obrigatórios.
|
|
</p>
|
|
<div class="flex flex-wrap gap-3">
|
|
<button
|
|
type="button"
|
|
class="btn btn-ghost"
|
|
onclick={fecharModalGerenciarPerfis}
|
|
disabled={processando}
|
|
>
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary"
|
|
disabled={!nomeNovoPerfil.trim() || processando}
|
|
onclick={perfilSendoEditado ? editarPerfil : criarNovoPerfil}
|
|
>
|
|
{#if processando}
|
|
<span class="loading loading-spinner"></span>
|
|
Processando...
|
|
{:else}
|
|
{perfilSendoEditado ? "Salvar Alterações" : "Criar Perfil"}
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<form method="dialog" class="modal-backdrop">
|
|
<button
|
|
type="button"
|
|
onclick={fecharModalGerenciarPerfis}
|
|
aria-label="Fechar modal"
|
|
>Fechar</button>
|
|
</form>
|
|
</dialog>
|
|
{/if}
|
|
</ProtectedRoute>
|