refactor: enhance role management and permissions handling

- Introduced a new mutation for creating roles with validation and slugification of names.
- Updated existing queries to improve role retrieval and error handling.
- Enhanced permission copying functionality when creating new roles.
- Improved code organization and readability by restructuring functions and adding type annotations.
This commit is contained in:
2025-11-12 10:24:56 -03:00
parent 1c56d71d43
commit 90bc5771ae
3 changed files with 1668 additions and 1645 deletions

View File

@@ -1,20 +1,10 @@
<script lang="ts"> <script lang="ts">
import { useQuery, useConvexClient } from "convex-svelte"; import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from "@sgse-app/backend/convex/_generated/api"; import { api } from '@sgse-app/backend/convex/_generated/api';
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte"; import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
import { goto } from "$app/navigation"; import { goto } from '$app/navigation';
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel"; import { resolve } from '$app/paths';
type RoleRow = { import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
_id: Id<"roles">;
_creationTime: number;
nome: string;
descricao: string;
nivel: number;
setor?: string;
customizado: boolean;
editavel?: boolean;
criadoPor?: Id<"usuarios">;
};
const client = useConvexClient(); const client = useConvexClient();
@@ -23,49 +13,43 @@
const catalogoQuery = useQuery(api.permissoesAcoes.listarRecursosEAcoes, {}); const catalogoQuery = useQuery(api.permissoesAcoes.listarRecursosEAcoes, {});
let salvando = $state(false); let salvando = $state(false);
let mensagem = $state<{ tipo: "success" | "error"; texto: string } | null>( let mensagem = $state<{ tipo: 'success' | 'error'; texto: string } | null>(null);
null, let busca = $state('');
); let filtroRole = $state<Id<'roles'> | ''>('');
let busca = $state(""); let modalNovoPerfilAberto = $state(false);
let filtroRole = $state(""); 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 // Controla quais recursos estão expandidos (mostrando as ações) por perfil
// Formato: { "roleId-recurso": true/false } // Formato: { "roleId-recurso": true/false }
let recursosExpandidos: Record<string, boolean> = $state({}); 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 // Cache de permissões por role
let permissoesPorRole: Record< let permissoesPorRole: Record<string, Array<{ recurso: string; acoes: Array<string> }>> = $state(
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 },
); );
async function carregarPermissoesRole(roleId: Id<'roles'>) {
if (permissoesPorRole[roleId]) return;
const dados = await client.query(api.permissoesAcoes.listarPermissoesAcoesPorRole, { roleId });
permissoesPorRole[roleId] = dados; permissoesPorRole[roleId] = dados;
} }
function toggleRecurso(roleId: Id<"roles">, recurso: string) { function toggleRecurso(roleId: Id<'roles'>, recurso: string) {
const key = `${roleId}-${recurso}`; const key = `${roleId}-${recurso}`;
recursosExpandidos[key] = !recursosExpandidos[key]; recursosExpandidos[key] = !recursosExpandidos[key];
} }
function isRecursoExpandido(roleId: Id<"roles">, recurso: string) { function isRecursoExpandido(roleId: Id<'roles'>, recurso: string) {
const key = `${roleId}-${recurso}`; const key = `${roleId}-${recurso}`;
return recursosExpandidos[key] ?? false; return recursosExpandidos[key] ?? false;
} }
function mostrarMensagem(tipo: "success" | "error", texto: string) { function mostrarMensagem(tipo: 'success' | 'error', texto: string) {
mensagem = { tipo, texto }; mensagem = { tipo, texto };
setTimeout(() => { setTimeout(() => {
mensagem = null; mensagem = null;
@@ -79,9 +63,7 @@
if (busca.trim()) { if (busca.trim()) {
const b = busca.toLowerCase(); const b = busca.toLowerCase();
rs = rs.filter( rs = rs.filter(
(r) => (r) => r.descricao.toLowerCase().includes(b) || r.nome.toLowerCase().includes(b)
r.descricao.toLowerCase().includes(b) ||
r.nome.toLowerCase().includes(b),
); );
} }
return rs; return rs;
@@ -98,143 +80,142 @@
} }
}); });
async function toggleAcao( async function toggleAcao(roleId: Id<'roles'>, recurso: string, acao: string, conceder: boolean) {
roleId: Id<"roles">,
recurso: string,
acao: string,
conceder: boolean,
) {
try { try {
salvando = true; salvando = true;
await client.mutation(api.permissoesAcoes.atualizarPermissaoAcao, { await client.mutation(api.permissoesAcoes.atualizarPermissaoAcao, {
roleId, roleId,
recurso, recurso,
acao, acao,
conceder, conceder
}); });
// Atualizar cache local // Atualizar cache local
const atual = permissoesPorRole[roleId] || []; const atual = permissoesPorRole[roleId] || [];
const entry = atual.find((e) => e.recurso === recurso); const entry = atual.find((e) => e.recurso === recurso);
if (entry) { if (entry) {
const set = new Set(entry.acoes); entry.acoes = conceder
if (conceder) set.add(acao); ? [...entry.acoes.filter((valor) => valor !== acao), acao]
else set.delete(acao); : entry.acoes.filter((valor) => valor !== acao);
entry.acoes = Array.from(set); } else if (conceder) {
} else { permissoesPorRole[roleId] = [...atual, { recurso, acoes: [acao] }];
permissoesPorRole[roleId] = [
...atual,
{ recurso, acoes: conceder ? [acao] : [] },
];
} }
mostrarMensagem("success", "Permissão atualizada com sucesso!"); mostrarMensagem('success', 'Permissão atualizada com sucesso!');
} catch (error: unknown) { } catch (error: unknown) {
// Changed to unknown // Changed to unknown
const message = const message = error instanceof Error ? error.message : 'Erro ao atualizar permissão';
error instanceof Error ? error.message : "Erro ao atualizar permissão"; mostrarMensagem('error', message);
mostrarMensagem("error", message);
} finally { } finally {
salvando = false; salvando = false;
} }
} }
function isConcedida(roleId: Id<"roles">, recurso: string, acao: string) { function isConcedida(roleId: Id<'roles'>, recurso: string, acao: string) {
const dados = permissoesPorRole[roleId]; const dados = permissoesPorRole[roleId];
const entry = dados?.find((e) => e.recurso === recurso); const entry = dados?.find((e) => e.recurso === recurso);
return entry ? entry.acoes.includes(acao) : false; return entry ? entry.acoes.includes(acao) : false;
} }
function abrirModalCriarPerfil() { const gerarIdentificador = (valor: string) =>
nomeNovoPerfil = ""; valor
descricaoNovoPerfil = ""; .normalize('NFD')
nivelNovoPerfil = 3; // Default to a common level .replace(/[\u0300-\u036f]/g, '')
perfilSendoEditado = null; .trim()
modalGerenciarPerfisAberto = true; .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 prepararEdicaoPerfil(role: RoleRow) { function fecharModalNovoPerfil() {
perfilSendoEditado = role; modalNovoPerfilAberto = false;
nomeNovoPerfil = role.nome;
descricaoNovoPerfil = role.descricao;
nivelNovoPerfil = role.nivel;
modalGerenciarPerfisAberto = true;
}
function fecharModalGerenciarPerfis() {
modalGerenciarPerfisAberto = false;
perfilSendoEditado = null;
} }
async function criarNovoPerfil() { async function criarNovoPerfil() {
if (!nomeNovoPerfil.trim()) return; if (!podeSalvarNovoPerfil) return;
processando = true; 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 { try {
const result = await client.mutation(api.roles.criar, { const resultado = await client.mutation(api.roles.criar, {
nome: nomeNovoPerfil.trim(), nome,
descricao: descricaoNovoPerfil.trim(), descricao,
nivel: nivelNovoPerfil, nivel,
customizado: true, setor: setor.length > 0 ? setor : undefined,
copiarDeRoleId: roleParaDuplicar || undefined
}); });
if (result.sucesso) { if (resultado.sucesso) {
mostrarMensagem("success", "Perfil criado com sucesso!"); mostrarMensagem('success', 'Perfil criado com sucesso!');
nomeNovoPerfil = ""; modalNovoPerfilAberto = false;
descricaoNovoPerfil = "";
nivelNovoPerfil = 3;
fecharModalGerenciarPerfis();
if (rolesQuery.refetch) {
// Verificação para garantir que refetch existe
rolesQuery.refetch(); // Atualiza a lista de perfis
}
} else { } else {
mostrarMensagem("error", `Erro ao criar perfil: ${result.erro}`); 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) { } catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error); const mensagemErro =
mostrarMensagem("error", `Erro ao criar perfil: ${message}`); error instanceof Error ? error.message : 'Erro inesperado ao criar o perfil.';
mostrarMensagem('error', mensagemErro);
} finally { } finally {
processando = false; criandoNovoPerfil = 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> </script>
<ProtectedRoute allowedRoles={["ti_master", "admin"]} maxLevel={1}> <ProtectedRoute allowedRoles={['ti_master', 'admin']} maxLevel={1}>
<!-- Breadcrumb --> <!-- Breadcrumb -->
<div class="text-sm breadcrumbs mb-4"> <div class="breadcrumbs mb-4 text-sm">
<ul> <ul>
<li> <li>
<a href="/" class="text-primary hover:text-primary-focus"> <a href={resolve('/')} class="text-primary hover:text-primary-focus">
<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"
@@ -253,7 +234,7 @@
</a> </a>
</li> </li>
<li> <li>
<a href="/ti" class="text-primary hover:text-primary-focus">TI</a> <a href={resolve('/ti')} class="text-primary hover:text-primary-focus">TI</a>
</li> </li>
<li class="font-semibold">Gerenciar Perfis & Permissões</li> <li class="font-semibold">Gerenciar Perfis & Permissões</li>
</ul> </ul>
@@ -261,11 +242,11 @@
<!-- Header --> <!-- Header -->
<div class="mb-6"> <div class="mb-6">
<div class="flex items-center gap-3 mb-2"> <div class="mb-2 flex flex-wrap items-center gap-3">
<div class="p-3 bg-primary/10 rounded-xl"> <div class="bg-primary/10 rounded-xl p-3">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-primary" class="text-primary h-8 w-8"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -279,14 +260,14 @@
</svg> </svg>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h1 class="text-3xl font-bold text-base-content"> <h1 class="text-base-content text-3xl font-bold">
Gerenciar Perfis & Permissões de Acesso Gerenciar Perfis & Permissões de Acesso
</h1> </h1>
<p class="text-base-content/60 mt-1"> <p class="text-base-content/60 mt-1">
Configure as permissões de acesso aos menus do sistema por função Configure as permissões de acesso aos menus do sistema por função
</p> </p>
</div> </div>
<button class="btn btn-primary gap-2" onclick={abrirModalCriarPerfil}> <button class="btn btn-primary gap-2" onclick={abrirModalNovoPerfil}>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5" class="h-5 w-5"
@@ -301,9 +282,9 @@
d="M12 4v16m8-8H4" d="M12 4v16m8-8H4"
/> />
</svg> </svg>
Criar Novo Perfil Criar novo perfil
</button> </button>
<button class="btn btn-ghost gap-2" onclick={() => goto("/ti")}> <button class="btn btn-ghost gap-2" onclick={() => goto(resolve('/ti'))}>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5" class="h-5 w-5"
@@ -327,13 +308,13 @@
{#if mensagem} {#if mensagem}
<div <div
class="alert mb-6 shadow-lg" class="alert mb-6 shadow-lg"
class:alert-success={mensagem.tipo === "success"} class:alert-success={mensagem.tipo === 'success'}
class:alert-error={mensagem.tipo === "error"} class:alert-error={mensagem.tipo === 'error'}
> >
{#if mensagem.tipo === "success"} {#if mensagem.tipo === 'success'}
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6" class="h-6 w-6 shrink-0 stroke-current"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
@@ -347,7 +328,7 @@
{:else} {:else}
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6" class="h-6 w-6 shrink-0 stroke-current"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
@@ -364,9 +345,9 @@
{/if} {/if}
<!-- Filtros e Busca --> <!-- Filtros e Busca -->
<div class="card bg-base-100 shadow-xl mb-6"> <div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body"> <div class="card-body">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Busca por menu --> <!-- Busca por menu -->
<div class="form-control"> <div class="form-control">
<label class="label" for="busca"> <label class="label" for="busca">
@@ -382,7 +363,7 @@
/> />
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 absolute right-3 top-3.5 text-base-content/40" class="text-base-content/40 absolute top-3.5 right-3 h-5 w-5"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -402,14 +383,10 @@
<label class="label" for="filtroRole"> <label class="label" for="filtroRole">
<span class="label-text font-semibold">Filtrar por Perfil</span> <span class="label-text font-semibold">Filtrar por Perfil</span>
</label> </label>
<select <select id="filtroRole" class="select select-bordered w-full" bind:value={filtroRole}>
id="filtroRole"
class="select select-bordered w-full"
bind:value={filtroRole}
>
<option value="">Todos os perfis</option> <option value="">Todos os perfis</option>
{#if rolesQuery.data} {#if rolesQuery.data}
{#each rolesQuery.data as roleRow} {#each rolesQuery.data as roleRow (roleRow._id)}
<option value={roleRow._id}> <option value={roleRow._id}>
{roleRow.descricao} ({roleRow.nome}) {roleRow.descricao} ({roleRow.nome})
</option> </option>
@@ -420,14 +397,14 @@
</div> </div>
{#if busca || filtroRole} {#if busca || filtroRole}
<div class="flex items-center gap-2 mt-2"> <div class="mt-2 flex items-center gap-2">
<span class="text-sm text-base-content/60">Filtros ativos:</span> <span class="text-base-content/60 text-sm">Filtros ativos:</span>
{#if busca} {#if busca}
<div class="badge badge-primary gap-2"> <div class="badge badge-primary gap-2">
Busca: {busca} Busca: {busca}
<button <button
class="btn btn-ghost btn-xs" class="btn btn-ghost btn-xs"
onclick={() => (busca = "")} onclick={() => (busca = '')}
aria-label="Limpar busca" aria-label="Limpar busca"
> >
@@ -439,7 +416,7 @@
Perfil filtrado Perfil filtrado
<button <button
class="btn btn-ghost btn-xs" class="btn btn-ghost btn-xs"
onclick={() => (filtroRole = "")} onclick={() => (filtroRole = '')}
aria-label="Limpar filtro" aria-label="Limpar filtro"
> >
@@ -455,7 +432,7 @@
<div class="alert alert-info mb-6 shadow-lg"> <div class="alert alert-info mb-6 shadow-lg">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6" class="h-6 w-6 shrink-0 stroke-current"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
@@ -467,11 +444,11 @@
/> />
</svg> </svg>
<div> <div>
<h3 class="font-bold text-lg">Como funciona o sistema de permissões:</h3> <h3 class="text-lg font-bold">Como funciona o sistema de permissões:</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3"> <div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<div> <div>
<h4 class="font-semibold text-sm">Tipos de Permissão:</h4> <h4 class="text-sm font-semibold">Tipos de Permissão:</h4>
<ul class="text-sm mt-1 space-y-1"> <ul class="mt-1 space-y-1 text-sm">
<li> <li>
<strong>Acessar:</strong> Visualizar menu e acessar página <strong>Acessar:</strong> Visualizar menu e acessar página
</li> </li>
@@ -482,13 +459,11 @@
</ul> </ul>
</div> </div>
<div> <div>
<h4 class="font-semibold text-sm">Perfis Especiais:</h4> <h4 class="text-sm font-semibold">Perfis Especiais:</h4>
<ul class="text-sm mt-1 space-y-1"> <ul class="mt-1 space-y-1 text-sm">
<li><strong>Admin e TI:</strong> Acesso total automático</li> <li><strong>Admin:</strong> Acesso total automático</li>
<li><strong>TI Master:</strong> Controle administrativo completo</li>
<li><strong>Dashboard:</strong> Público para todos</li> <li><strong>Dashboard:</strong> Público para todos</li>
<li>
<strong>Perfil Customizado:</strong> Permissões personalizadas
</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -497,14 +472,14 @@
<!-- Matriz de Permissões por Ação --> <!-- Matriz de Permissões por Ação -->
{#if rolesQuery.isLoading || catalogoQuery.isLoading} {#if rolesQuery.isLoading || catalogoQuery.isLoading}
<div class="flex justify-center items-center py-12"> <div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span> <span class="loading loading-spinner loading-lg text-primary"></span>
</div> </div>
{:else if rolesQuery.error} {:else if rolesQuery.error}
<div class="alert alert-error"> <div class="alert alert-error">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6" class="h-6 w-6 shrink-0 stroke-current"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
@@ -523,7 +498,7 @@
<div class="card-body items-center text-center"> <div class="card-body items-center text-center">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 text-base-content/30" class="text-base-content/30 h-16 w-16"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -535,17 +510,17 @@
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/> />
</svg> </svg>
<h3 class="text-xl font-bold mt-4">Nenhum resultado encontrado</h3> <h3 class="mt-4 text-xl font-bold">Nenhum resultado encontrado</h3>
<p class="text-base-content/60"> <p class="text-base-content/60">
{busca {busca
? `Não foram encontrados perfis com "${busca}"` ? `Não foram encontrados perfis com "${busca}"`
: "Nenhum perfil corresponde aos filtros aplicados"} : 'Nenhum perfil corresponde aos filtros aplicados'}
</p> </p>
<button <button
class="btn btn-primary btn-sm mt-4" class="btn btn-primary btn-sm mt-4"
onclick={() => { onclick={() => {
busca = ""; busca = '';
filtroRole = ""; filtroRole = '';
}} }}
> >
Limpar Filtros Limpar Filtros
@@ -554,13 +529,13 @@
</div> </div>
{/if} {/if}
{#each rolesFiltradas as roleRow} {#each rolesFiltradas as roleRow (roleRow._id)}
{@const roleId = roleRow._id} {@const roleId = roleRow._id}
<div class="card bg-base-100 shadow-xl mb-6"> <div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body"> <div class="card-body">
<div class="flex items-center justify-between mb-4 flex-wrap gap-4"> <div class="mb-4 flex flex-wrap items-center gap-4">
<div class="flex-1 min-w-[200px]"> <div class="min-w-[200px] flex-1">
<div class="flex items-center gap-3 mb-2"> <div class="mb-2 flex items-center gap-3">
<h2 class="card-title text-2xl">{roleRow.descricao}</h2> <h2 class="card-title text-2xl">{roleRow.descricao}</h2>
<div class="badge badge-lg badge-primary"> <div class="badge badge-lg badge-primary">
Nível {roleRow.nivel} Nível {roleRow.nivel}
@@ -585,28 +560,17 @@
</div> </div>
{/if} {/if}
</div> </div>
<p class="text-sm text-base-content/60"> <p class="text-base-content/60 text-sm">
<span class="font-mono bg-base-200 px-2 py-1 rounded" <span class="bg-base-200 rounded px-2 py-1 font-mono">{roleRow.nome}</span>
>{roleRow.nome}</span
>
</p> </p>
</div> </div>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-outline btn-sm"
onclick={() => prepararEdicaoPerfil(roleRow)}
>
Editar
</button>
</div>
</div> </div>
{#if roleRow.nivel <= 1} {#if roleRow.nivel <= 1}
<div class="alert alert-success shadow-md"> <div class="alert alert-success shadow-md">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6" class="h-6 w-6 shrink-0 stroke-current"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
@@ -620,27 +584,24 @@
<div> <div>
<h3 class="font-bold">Perfil Administrativo</h3> <h3 class="font-bold">Perfil Administrativo</h3>
<div class="text-sm"> <div class="text-sm">
Este perfil possui acesso total ao sistema automaticamente, Este perfil possui acesso total ao sistema automaticamente, sem necessidade de
sem necessidade de configuração manual. configuração manual.
</div> </div>
</div> </div>
</div> </div>
{:else if catalogoQuery.data} {:else if catalogoQuery.data}
<div class="space-y-2"> <div class="space-y-2">
{#each catalogoQuery.data as item} {#each catalogoQuery.data as item (item.recurso)}
{@const recursoExpandido = isRecursoExpandido( {@const recursoExpandido = isRecursoExpandido(roleId, item.recurso)}
roleId, <div class="border-base-300 overflow-hidden rounded-lg border">
item.recurso,
)}
<div class="border border-base-300 rounded-lg overflow-hidden">
<!-- Cabeçalho do recurso (clicável) --> <!-- Cabeçalho do recurso (clicável) -->
<button <button
type="button" type="button"
class="w-full px-4 py-3 bg-base-200 hover:bg-base-300 transition-colors flex items-center justify-between" 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)} onclick={() => toggleRecurso(roleId, item.recurso)}
disabled={salvando} disabled={salvando}
> >
<span class="font-semibold text-lg">{item.recurso}</span> <span class="text-lg font-semibold">{item.recurso}</span>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 transition-transform" class="h-5 w-5 transition-transform"
@@ -660,11 +621,11 @@
<!-- Lista de ações (visível quando expandido) --> <!-- Lista de ações (visível quando expandido) -->
{#if recursoExpandido} {#if recursoExpandido}
<div class="px-4 py-3 bg-base-100 border-t border-base-300"> <div class="bg-base-100 border-base-300 border-t px-4 py-3">
<div class="space-y-2"> <div class="space-y-2">
{#each ["ver", "listar", "criar", "editar", "excluir"] as acao} {#each ['ver', 'listar', 'criar', 'editar', 'excluir'] as acao (acao)}
<label <label
class="flex items-center gap-3 cursor-pointer hover:bg-base-200 p-2 rounded transition-colors" class="hover:bg-base-200 flex cursor-pointer items-center gap-3 rounded p-2 transition-colors"
> >
<input <input
type="checkbox" type="checkbox"
@@ -672,16 +633,9 @@
checked={isConcedida(roleId, item.recurso, acao)} checked={isConcedida(roleId, item.recurso, acao)}
disabled={salvando} disabled={salvando}
onchange={(e) => onchange={(e) =>
toggleAcao( toggleAcao(roleId, item.recurso, acao, e.currentTarget.checked)}
roleId,
item.recurso,
acao,
e.currentTarget.checked,
)}
/> />
<span class="flex-1 capitalize font-medium" <span class="flex-1 font-medium capitalize">{acao}</span>
>{acao}</span
>
</label> </label>
{/each} {/each}
</div> </div>
@@ -696,140 +650,176 @@
{/each} {/each}
{/if} {/if}
<!-- Modal Gerenciar Perfis --> {#if modalNovoPerfilAberto}
{#if modalGerenciarPerfisAberto}
<dialog class="modal modal-open"> <dialog class="modal modal-open">
<div <div
class="modal-box max-w-4xl w-full overflow-hidden border border-base-200/60 bg-base-200/40 p-0 shadow-2xl" class="modal-box bg-base-100 w-full max-w-4xl overflow-hidden rounded-2xl p-0 shadow-2xl"
> >
<div <div
class="relative bg-linear-to-r from-primary via-primary/90 to-secondary/80 px-8 py-6 text-base-100" class="from-primary via-primary/85 to-secondary/80 text-base-100 relative bg-linear-to-r px-8 py-6"
> >
<button <button
type="button" type="button"
class="btn btn-sm btn-circle btn-ghost absolute right-4 top-4 text-base-100/80 hover:text-base-100" class="btn btn-circle btn-ghost btn-sm text-base-100/80 hover:text-base-100 absolute top-4 right-4"
onclick={fecharModalGerenciarPerfis} onclick={fecharModalNovoPerfil}
aria-label="Fechar modal" aria-label="Fechar"
> >
</button> </button>
<div class="space-y-2 pr-10">
<h3 class="text-3xl font-black tracking-tight"> <h3 class="text-3xl font-black tracking-tight">Criar novo perfil de acesso</h3>
Gerenciar Perfis de Acesso <p class="text-base-100/80 max-w-2xl text-sm md:text-base">
</h3> Defina as informações do perfil e, se desejar, reutilize as permissões de um perfil
<p class="mt-2 max-w-2xl text-sm text-base-100/80 md:text-base"> existente para acelerar a configuração.
{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> </p>
</div> {#if identificadorSugerido}
<div class="text-base-100/80 flex flex-wrap items-center gap-2 text-xs">
<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 <span
class="badge badge-outline badge-primary badge-lg self-start flex flex-col items-center gap-1 px-5 py-3 text-center" class="badge badge-outline badge-sm border-base-100/40 tracking-widest uppercase"
> >
<span Identificador
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> </span>
<span class="font-mono text-sm">{identificadorSugerido}</span>
</div>
{/if} {/if}
</div> </div>
</div>
<div class="space-y-6 px-8 py-6">
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
<div class="form-control md:col-span-2"> <div class="form-control md:col-span-2">
<label class="label" for="nome-perfil-input"> <label class="label" for="nome-novo-perfil">
<span class="label-text">Nome do Perfil *</span> <span class="label-text font-semibold">Nome do perfil *</span>
</label> </label>
<input <input
id="nome-perfil-input" id="nome-novo-perfil"
type="text" type="text"
bind:value={nomeNovoPerfil}
class="input input-bordered input-primary" class="input input-bordered input-primary"
placeholder="Ex: RH, Financeiro, Gestor" placeholder="Ex.: Financeiro, RH, Supervisão de Campo"
disabled={perfilSendoEditado !== null && bind:value={nomeNovoPerfil}
!perfilSendoEditado.customizado} maxlength={60}
/> />
</div> </div>
<div class="form-control md:col-span-2"> <div class="form-control md:col-span-2">
<label class="label" for="descricao-perfil-input"> <label class="label" for="descricao-novo-perfil">
<span class="label-text">Descrição</span> <span class="label-text font-semibold">Descrição</span>
</label> </label>
<textarea <textarea
id="descricao-perfil-input" 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} bind:value={descricaoNovoPerfil}
class="textarea textarea-bordered textarea-primary min-h-[120px]" maxlength={240}
placeholder="Breve descrição das responsabilidades e limites de atuação deste perfil."
></textarea> ></textarea>
</div> </div>
<div class="form-control max-w-xs"> <div class="form-control">
<label class="label" for="nivel-perfil-input"> <label class="label" for="setor-novo-perfil">
<span class="label-text">Nível de Acesso (0-5) *</span> <span class="label-text font-semibold">Setor (opcional)</span>
</label> </label>
<input <input
id="nivel-perfil-input" id="setor-novo-perfil"
type="number" type="text"
bind:value={nivelNovoPerfil} class="input input-bordered"
min="0" placeholder="Informe o setor ou departamento responsável"
max="5" bind:value={setorNovoPerfil}
class="input input-bordered input-secondary" maxlength={40}
disabled={perfilSendoEditado !== null &&
!perfilSendoEditado.customizado}
/> />
</div> </div>
</div>
</section>
<div <div class="form-control">
class="mt-8 flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between" <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}
> >
<p class="text-sm text-base-content/60"> <option value="">Iniciar perfil vazio</option>
Campos marcados com * são obrigatórios. {#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> </p>
<div class="flex flex-wrap gap-3"> <div class="flex flex-wrap gap-3">
<button <button
type="button" type="button"
class="btn btn-ghost" class="btn btn-ghost"
onclick={fecharModalGerenciarPerfis} onclick={fecharModalNovoPerfil}
disabled={processando} disabled={criandoNovoPerfil}
> >
Cancelar Cancelar
</button> </button>
<button <button
type="button" type="button"
class="btn btn-primary" class="btn btn-primary"
disabled={!nomeNovoPerfil.trim() || processando} disabled={!podeSalvarNovoPerfil}
onclick={perfilSendoEditado ? editarPerfil : criarNovoPerfil} onclick={criarNovoPerfil}
> >
{#if processando} {#if criandoNovoPerfil}
<span class="loading loading-spinner"></span> <span class="loading loading-spinner"></span>
Processando... Criando perfil...
{:else} {:else}
{perfilSendoEditado ? "Salvar Alterações" : "Criar Perfil"} Salvar perfil
{/if} {/if}
</button> </button>
</div> </div>
@@ -837,11 +827,9 @@
</div> </div>
</div> </div>
<form method="dialog" class="modal-backdrop"> <form method="dialog" class="modal-backdrop">
<button <button type="button" onclick={fecharModalNovoPerfil} aria-label="Fechar modal">
type="button" fechar
onclick={fecharModalGerenciarPerfis} </button>
aria-label="Fechar modal">Fechar</button
>
</form> </form>
</dialog> </dialog>
{/if} {/if}

View File

@@ -1,13 +1,14 @@
<script lang="ts"> <script lang="ts">
import { useConvexClient, useQuery } from "convex-svelte"; import { useConvexClient, useQuery } from 'convex-svelte';
import { api } from "@sgse-app/backend/convex/_generated/api"; import { api } from '@sgse-app/backend/convex/_generated/api';
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte"; import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
import { goto } from "$app/navigation"; import { goto } from '$app/navigation';
import type { Id, Doc } from "@sgse-app/backend/convex/_generated/dataModel"; import { resolve } from '$app/paths';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
// Tipos baseados nos retornos das queries do backend // Tipos baseados nos retornos das queries do backend
type Usuario = { type Usuario = {
_id: Id<"usuarios">; _id: Id<'usuarios'>;
matricula: string; matricula: string;
nome: string; nome: string;
email: string; email: string;
@@ -18,9 +19,9 @@
ultimoAcesso?: number; ultimoAcesso?: number;
criadoEm: number; criadoEm: number;
role: { role: {
_id: Id<"roles">; _id: Id<'roles'>;
_creationTime?: number; _creationTime?: number;
criadoPor?: Id<"usuarios">; criadoPor?: Id<'usuarios'>;
customizado?: boolean; customizado?: boolean;
descricao: string; descricao: string;
editavel?: boolean; editavel?: boolean;
@@ -30,20 +31,20 @@
erro?: boolean; erro?: boolean;
}; };
funcionario?: { funcionario?: {
_id: Id<"funcionarios">; _id: Id<'funcionarios'>;
nome: string; nome: string;
matricula?: string; matricula?: string;
descricaoCargo?: string; descricaoCargo?: string;
simboloTipo: "cargo_comissionado" | "funcao_gratificada"; simboloTipo: 'cargo_comissionado' | 'funcao_gratificada';
}; };
avisos?: Array<{ avisos?: Array<{
tipo: "erro" | "aviso" | "info"; tipo: 'erro' | 'aviso' | 'info';
mensagem: string; mensagem: string;
}>; }>;
}; };
type Funcionario = { type Funcionario = {
_id: Id<"funcionarios">; _id: Id<'funcionarios'>;
nome: string; nome: string;
matricula?: string; matricula?: string;
cpf?: string; cpf?: string;
@@ -55,25 +56,25 @@
cep?: string; cep?: string;
cidade?: string; cidade?: string;
uf?: string; uf?: string;
simboloId: Id<"simbolos">; simboloId: Id<'simbolos'>;
simboloTipo: "cargo_comissionado" | "funcao_gratificada"; simboloTipo: 'cargo_comissionado' | 'funcao_gratificada';
admissaoData?: string; admissaoData?: string;
desligamentoData?: string; desligamentoData?: string;
descricaoCargo?: string; descricaoCargo?: string;
}; };
type Gestor = Doc<"usuarios"> | null; type Gestor = Doc<'usuarios'> | null;
type TimeComDetalhes = Doc<"times"> & { type TimeComDetalhes = Doc<'times'> & {
gestor: Gestor; gestor: Gestor;
totalMembros: number; totalMembros: number;
}; };
type MembroTime = Doc<"timesMembros"> & { type MembroTime = Doc<'timesMembros'> & {
funcionario: Doc<"funcionarios"> | null; funcionario: Doc<'funcionarios'> | null;
}; };
type TimeComMembros = Doc<"times"> & { type TimeComMembros = Doc<'times'> & {
gestor: Gestor; gestor: Gestor;
membros: MembroTime[]; membros: MembroTime[];
}; };
@@ -87,14 +88,10 @@
const times = $derived((timesQuery?.data || []) as TimeComDetalhes[]); const times = $derived((timesQuery?.data || []) as TimeComDetalhes[]);
const usuarios = $derived((usuariosQuery?.data || []) as Usuario[]); const usuarios = $derived((usuariosQuery?.data || []) as Usuario[]);
const funcionarios = $derived( const funcionarios = $derived((funcionariosQuery?.data || []) as Funcionario[]);
(funcionariosQuery?.data || []) as Funcionario[],
);
const carregando = $derived( const carregando = $derived(
timesQuery === undefined || timesQuery === undefined || usuariosQuery === undefined || funcionariosQuery === undefined
usuariosQuery === undefined ||
funcionariosQuery === undefined,
); );
// Estados // Estados
@@ -107,65 +104,64 @@
let processando = $state(false); let processando = $state(false);
// Form // Form
let formNome = $state(""); let formNome = $state('');
let formDescricao = $state(""); let formDescricao = $state('');
let formGestorId = $state(""); let formGestorId = $state('');
let formCor = $state("#3B82F6"); let formCor = $state('#3B82F6');
// Membros // Membros
let membrosDisponiveis = $derived( let membrosDisponiveis = $derived(
funcionarios.filter((f: Funcionario) => { funcionarios.filter((f: Funcionario) => {
// Verificar se o funcionário já está em algum time ativo // Verificar se o funcionário já está em algum time ativo
const jaNaEquipe = timeParaMembros?.membros?.some( const jaNaEquipe = timeParaMembros?.membros?.some(
(m: MembroTime) => m.funcionario?._id === f._id, (m: MembroTime) => m.funcionario?._id === f._id
); );
return !jaNaEquipe; return !jaNaEquipe;
}), })
); );
// Cores predefinidas // Cores predefinidas
const coresDisponiveis = [ const coresDisponiveis = [
"#3B82F6", // Blue '#3B82F6', // Blue
"#10B981", // Green '#10B981', // Green
"#F59E0B", // Yellow '#F59E0B', // Yellow
"#EF4444", // Red '#EF4444', // Red
"#8B5CF6", // Purple '#8B5CF6', // Purple
"#EC4899", // Pink '#EC4899', // Pink
"#14B8A6", // Teal '#14B8A6', // Teal
"#F97316", // Orange '#F97316' // Orange
]; ];
function novoTime() { function novoTime() {
modoEdicao = true; modoEdicao = true;
timeEmEdicao = null; timeEmEdicao = null;
formNome = ""; formNome = '';
formDescricao = ""; formDescricao = '';
formGestorId = ""; formGestorId = '';
formCor = formCor = coresDisponiveis[Math.floor(Math.random() * coresDisponiveis.length)];
coresDisponiveis[Math.floor(Math.random() * coresDisponiveis.length)];
} }
function editarTime(time: TimeComDetalhes) { function editarTime(time: TimeComDetalhes) {
modoEdicao = true; modoEdicao = true;
timeEmEdicao = time; timeEmEdicao = time;
formNome = time.nome; formNome = time.nome;
formDescricao = time.descricao || ""; formDescricao = time.descricao || '';
formGestorId = time.gestorId; formGestorId = time.gestorId;
formCor = time.cor || "#3B82F6"; formCor = time.cor || '#3B82F6';
} }
function cancelarEdicao() { function cancelarEdicao() {
modoEdicao = false; modoEdicao = false;
timeEmEdicao = null; timeEmEdicao = null;
formNome = ""; formNome = '';
formDescricao = ""; formDescricao = '';
formGestorId = ""; formGestorId = '';
formCor = "#3B82F6"; formCor = '#3B82F6';
} }
async function salvarTime() { async function salvarTime() {
if (!formNome.trim() || !formGestorId) { if (!formNome.trim() || !formGestorId) {
alert("Preencha todos os campos obrigatórios!"); alert('Preencha todos os campos obrigatórios!');
return; return;
} }
@@ -176,21 +172,21 @@
id: timeEmEdicao._id, id: timeEmEdicao._id,
nome: formNome, nome: formNome,
descricao: formDescricao || undefined, descricao: formDescricao || undefined,
gestorId: formGestorId as Id<"usuarios">, gestorId: formGestorId as Id<'usuarios'>,
cor: formCor, cor: formCor
}); });
} else { } else {
await client.mutation(api.times.criar, { await client.mutation(api.times.criar, {
nome: formNome, nome: formNome,
descricao: formDescricao || undefined, descricao: formDescricao || undefined,
gestorId: formGestorId as Id<"usuarios">, gestorId: formGestorId as Id<'usuarios'>,
cor: formCor, cor: formCor
}); });
} }
cancelarEdicao(); cancelarEdicao();
} catch (e: unknown) { } catch (e: unknown) {
const errorMessage = e instanceof Error ? e.message : String(e); const errorMessage = e instanceof Error ? e.message : String(e);
alert("Erro ao salvar: " + errorMessage); alert('Erro ao salvar: ' + errorMessage);
} finally { } finally {
processando = false; processando = false;
} }
@@ -211,7 +207,7 @@
timeParaExcluir = null; timeParaExcluir = null;
} catch (e: unknown) { } catch (e: unknown) {
const errorMessage = e instanceof Error ? e.message : String(e); const errorMessage = e instanceof Error ? e.message : String(e);
alert("Erro ao excluir: " + errorMessage); alert('Erro ao excluir: ' + errorMessage);
} finally { } finally {
processando = false; processando = false;
} }
@@ -232,37 +228,37 @@
try { try {
await client.mutation(api.times.adicionarMembro, { await client.mutation(api.times.adicionarMembro, {
timeId: timeParaMembros._id, timeId: timeParaMembros._id,
funcionarioId: funcionarioId as Id<"funcionarios">, funcionarioId: funcionarioId as Id<'funcionarios'>
}); });
// Recarregar detalhes do time // Recarregar detalhes do time
const detalhes = await client.query(api.times.obterPorId, { const detalhes = await client.query(api.times.obterPorId, {
id: timeParaMembros._id, id: timeParaMembros._id
}); });
if (detalhes) { if (detalhes) {
timeParaMembros = detalhes as TimeComMembros; timeParaMembros = detalhes as TimeComMembros;
} }
} catch (e: unknown) { } catch (e: unknown) {
const errorMessage = e instanceof Error ? e.message : String(e); const errorMessage = e instanceof Error ? e.message : String(e);
alert("Erro: " + errorMessage); alert('Erro: ' + errorMessage);
} finally { } finally {
processando = false; processando = false;
} }
} }
async function removerMembro(membroId: string) { async function removerMembro(membroId: string) {
if (!confirm("Deseja realmente remover este membro do time?")) return; if (!confirm('Deseja realmente remover este membro do time?')) return;
processando = true; processando = true;
try { try {
await client.mutation(api.times.removerMembro, { await client.mutation(api.times.removerMembro, {
membroId: membroId as Id<"timesMembros">, membroId: membroId as Id<'timesMembros'>
}); });
// Recarregar detalhes do time // Recarregar detalhes do time
if (timeParaMembros) { if (timeParaMembros) {
const detalhes = await client.query(api.times.obterPorId, { const detalhes = await client.query(api.times.obterPorId, {
id: timeParaMembros._id, id: timeParaMembros._id
}); });
if (detalhes) { if (detalhes) {
timeParaMembros = detalhes as TimeComMembros; timeParaMembros = detalhes as TimeComMembros;
@@ -270,7 +266,7 @@
} }
} catch (e: unknown) { } catch (e: unknown) {
const errorMessage = e instanceof Error ? e.message : String(e); const errorMessage = e instanceof Error ? e.message : String(e);
alert("Erro: " + errorMessage); alert('Erro: ' + errorMessage);
} finally { } finally {
processando = false; processando = false;
} }
@@ -282,18 +278,13 @@
} }
</script> </script>
<ProtectedRoute <ProtectedRoute allowedRoles={['ti_master', 'admin', 'ti_usuario']} maxLevel={2}>
allowedRoles={["ti_master", "admin", "ti_usuario"]} <main class="container mx-auto max-w-7xl px-4 py-6">
maxLevel={2}
>
<main class="container mx-auto px-4 py-6 max-w-7xl">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<div class="text-sm breadcrumbs mb-4"> <div class="breadcrumbs mb-4 text-sm">
<ul> <ul>
<li> <li>
<a href="/ti" class="text-primary hover:underline" <a href={resolve('/ti')} class="text-primary hover:underline">Tecnologia da Informação</a>
>Tecnologia da Informação</a
>
</li> </li>
<li>Gestão de Times</li> <li>Gestão de Times</li>
</ul> </ul>
@@ -303,10 +294,10 @@
<div class="mb-6"> <div class="mb-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="p-3 bg-secondary/10 rounded-xl"> <div class="bg-secondary/10 rounded-xl p-3">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-secondary" class="text-secondary h-8 w-8"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -320,16 +311,14 @@
</svg> </svg>
</div> </div>
<div> <div>
<h1 class="text-3xl font-bold text-base-content"> <h1 class="text-base-content text-3xl font-bold">Gestão de Times</h1>
Gestão de Times
</h1>
<p class="text-base-content/60 mt-1"> <p class="text-base-content/60 mt-1">
Organize funcionários em equipes e defina gestores Organize funcionários em equipes e defina gestores
</p> </p>
</div> </div>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button class="btn btn-ghost gap-2" onclick={() => goto("/ti")}> <button class="btn btn-ghost gap-2" onclick={() => goto(resolve('/ti'))}>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5" class="h-5 w-5"
@@ -370,14 +359,14 @@
<!-- Formulário de Edição --> <!-- Formulário de Edição -->
{#if modoEdicao} {#if modoEdicao}
<div <div
class="card bg-linear-to-br from-primary/10 to-primary/5 shadow-xl mb-6 border-2 border-primary/20" class="card from-primary/10 to-primary/5 border-primary/20 mb-6 border-2 bg-linear-to-br shadow-xl"
> >
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-xl mb-4"> <h2 class="card-title mb-4 text-xl">
{timeEmEdicao ? "Editar Time" : "Novo Time"} {timeEmEdicao ? 'Editar Time' : 'Novo Time'}
</h2> </h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control"> <div class="form-control">
<label class="label" for="nome"> <label class="label" for="nome">
<span class="label-text font-semibold">Nome do Time *</span> <span class="label-text font-semibold">Nome do Time *</span>
@@ -395,13 +384,9 @@
<label class="label" for="gestor"> <label class="label" for="gestor">
<span class="label-text font-semibold">Gestor *</span> <span class="label-text font-semibold">Gestor *</span>
</label> </label>
<select <select id="gestor" class="select select-bordered" bind:value={formGestorId}>
id="gestor"
class="select select-bordered"
bind:value={formGestorId}
>
<option value="">Selecione um gestor</option> <option value="">Selecione um gestor</option>
{#each usuarios as usuario} {#each usuarios as usuario (usuario._id)}
<option value={usuario._id}>{usuario.nome}</option> <option value={usuario._id}>{usuario.nome}</option>
{/each} {/each}
</select> </select>
@@ -424,15 +409,12 @@
<label class="label" for="cor"> <label class="label" for="cor">
<span class="label-text font-semibold">Cor do Time</span> <span class="label-text font-semibold">Cor do Time</span>
</label> </label>
<div class="flex gap-2 flex-wrap"> <div class="flex flex-wrap gap-2">
{#each coresDisponiveis as cor} {#each coresDisponiveis as cor (cor)}
<button <button
type="button" type="button"
class="w-10 h-10 rounded-lg border-2 transition-all hover:scale-110" class="h-10 w-10 rounded-lg border-2 transition-all hover:scale-110"
style="background-color: {cor}; border-color: {formCor === style="background-color: {cor}; border-color: {formCor === cor ? '#000' : cor}"
cor
? '#000'
: cor}"
onclick={() => (formCor = cor)} onclick={() => (formCor = cor)}
aria-label="Selecionar cor" aria-label="Selecionar cor"
></button> ></button>
@@ -441,20 +423,12 @@
</div> </div>
</div> </div>
<div class="card-actions justify-end mt-6"> <div class="card-actions mt-6 justify-end">
<button <button class="btn btn-ghost" onclick={cancelarEdicao} disabled={processando}>
class="btn btn-ghost"
onclick={cancelarEdicao}
disabled={processando}
>
Cancelar Cancelar
</button> </button>
<button <button class="btn btn-primary" onclick={salvarTime} disabled={processando}>
class="btn btn-primary" {processando ? 'Salvando...' : 'Salvar'}
onclick={salvarTime}
disabled={processando}
>
{processando ? "Salvando..." : "Salvar"}
</button> </button>
</div> </div>
</div> </div>
@@ -463,21 +437,21 @@
<!-- Lista de Times --> <!-- Lista de Times -->
{#if carregando} {#if carregando}
<div class="flex justify-center items-center py-20"> <div class="flex items-center justify-center py-20">
<span class="loading loading-spinner loading-lg text-primary"></span> <span class="loading loading-spinner loading-lg text-primary"></span>
</div> </div>
{:else} {:else}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each times.filter((t: TimeComDetalhes) => t.ativo) as time} {#each times.filter((t: TimeComDetalhes) => t.ativo) as time (time._id)}
<div <div
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all border-l-4" class="card bg-base-100 border-l-4 shadow-xl transition-all hover:shadow-2xl"
style="border-color: {time.cor || '#3B82F6'}" style="border-color: {time.cor || '#3B82F6'}"
> >
<div class="card-body"> <div class="card-body">
<div class="flex items-start justify-between mb-2"> <div class="mb-2 flex items-start justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div
class="w-3 h-3 rounded-full" class="h-3 w-3 rounded-full"
style="background-color: {time.cor || '#3B82F6'}" style="background-color: {time.cor || '#3B82F6'}"
></div> ></div>
<h2 class="card-title text-lg">{time.nome}</h2> <h2 class="card-title text-lg">{time.nome}</h2>
@@ -505,7 +479,7 @@
</button> </button>
<ul <ul
role="menu" role="menu"
class="dropdown-content z-[1] menu p-2 shadow-xl bg-base-100 rounded-box w-52 border border-base-300" class="dropdown-content menu bg-base-100 rounded-box border-base-300 z-1 w-52 border p-2 shadow-xl"
> >
<li> <li>
<button type="button" onclick={() => editarTime(time)}> <button type="button" onclick={() => editarTime(time)}>
@@ -527,10 +501,7 @@
</button> </button>
</li> </li>
<li> <li>
<button <button type="button" onclick={() => abrirGerenciarMembros(time)}>
type="button"
onclick={() => abrirGerenciarMembros(time)}
>
<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"
@@ -575,8 +546,8 @@
</div> </div>
</div> </div>
<p class="text-sm text-base-content/70 mb-3 min-h-[2rem]"> <p class="text-base-content/70 mb-3 min-h-8 text-sm">
{time.descricao || "Sem descrição"} {time.descricao || 'Sem descrição'}
</p> </p>
<div class="divider my-2"></div> <div class="divider my-2"></div>
@@ -585,7 +556,7 @@
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-primary" class="text-primary h-4 w-4"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -599,13 +570,13 @@
</svg> </svg>
<span class="text-base-content/70" <span class="text-base-content/70"
><strong>Gestor:</strong> ><strong>Gestor:</strong>
{time.gestor?.nome || "Não definido"}</span {time.gestor?.nome || 'Não definido'}</span
> >
</div> </div>
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-primary" class="text-primary h-4 w-4"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -619,14 +590,12 @@
</svg> </svg>
<span class="text-base-content/70" <span class="text-base-content/70"
><strong>Membros:</strong> ><strong>Membros:</strong>
<span class="badge badge-primary badge-sm" <span class="badge badge-primary badge-sm">{time.totalMembros || 0}</span></span
>{time.totalMembros || 0}</span
></span
> >
</div> </div>
</div> </div>
<div class="card-actions justify-end mt-4"> <div class="card-actions mt-4 justify-end">
<button <button
class="btn btn-sm btn-outline btn-primary" class="btn btn-sm btn-outline btn-primary"
onclick={() => abrirGerenciarMembros(time)} onclick={() => abrirGerenciarMembros(time)}
@@ -640,12 +609,10 @@
{#if times.filter((t: TimeComDetalhes) => t.ativo).length === 0} {#if times.filter((t: TimeComDetalhes) => t.ativo).length === 0}
<div class="col-span-full"> <div class="col-span-full">
<div <div class="flex flex-col items-center justify-center py-16 text-center">
class="flex flex-col items-center justify-center py-16 text-center"
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 text-base-content/30" class="text-base-content/30 h-16 w-16"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -657,7 +624,7 @@
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/> />
</svg> </svg>
<h3 class="text-xl font-semibold mt-4">Nenhum time cadastrado</h3> <h3 class="mt-4 text-xl font-semibold">Nenhum time cadastrado</h3>
<p class="text-base-content/60 mt-2"> <p class="text-base-content/60 mt-2">
Clique em "Novo Time" para criar seu primeiro time Clique em "Novo Time" para criar seu primeiro time
</p> </p>
@@ -671,35 +638,26 @@
{#if mostrarModalMembros && timeParaMembros} {#if mostrarModalMembros && timeParaMembros}
<dialog class="modal modal-open"> <dialog class="modal modal-open">
<div class="modal-box max-w-4xl"> <div class="modal-box max-w-4xl">
<h3 class="font-bold text-2xl mb-4 flex items-center gap-2"> <h3 class="mb-4 flex items-center gap-2 text-2xl font-bold">
<div <div class="h-3 w-3 rounded-full" style="background-color: {timeParaMembros.cor}"></div>
class="w-3 h-3 rounded-full"
style="background-color: {timeParaMembros.cor}"
></div>
{timeParaMembros.nome} {timeParaMembros.nome}
</h3> </h3>
<!-- Membros Atuais --> <!-- Membros Atuais -->
<div class="mb-6"> <div class="mb-6">
<h4 class="font-bold text-lg mb-3"> <h4 class="mb-3 text-lg font-bold">
Membros Atuais ({timeParaMembros.membros?.length || 0}) Membros Atuais ({timeParaMembros.membros?.length || 0})
</h4> </h4>
{#if timeParaMembros.membros && timeParaMembros.membros.length > 0} {#if timeParaMembros.membros && timeParaMembros.membros.length > 0}
<div class="space-y-2 max-h-60 overflow-y-auto"> <div class="max-h-60 space-y-2 overflow-y-auto">
{#each timeParaMembros.membros as membro} {#each timeParaMembros.membros as membro (membro._id)}
<div <div class="bg-base-200 flex items-center justify-between rounded-lg p-3">
class="flex items-center justify-between p-3 bg-base-200 rounded-lg"
>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="avatar placeholder"> <div class="avatar placeholder">
<div <div class="bg-primary text-primary-content w-10 rounded-full">
class="bg-primary text-primary-content rounded-full w-10"
>
<span class="text-xs" <span class="text-xs"
>{membro.funcionario?.nome >{membro.funcionario?.nome.substring(0, 2).toUpperCase()}</span
.substring(0, 2)
.toUpperCase()}</span
> >
</div> </div>
</div> </div>
@@ -707,10 +665,8 @@
<div class="font-semibold"> <div class="font-semibold">
{membro.funcionario?.nome} {membro.funcionario?.nome}
</div> </div>
<div class="text-xs text-base-content/50"> <div class="text-base-content/50 text-xs">
Desde {new Date( Desde {new Date(membro.dataEntrada).toLocaleDateString('pt-BR')}
membro.dataEntrada,
).toLocaleDateString("pt-BR")}
</div> </div>
</div> </div>
</div> </div>
@@ -744,7 +700,7 @@
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
class="stroke-current shrink-0 w-6 h-6" class="h-6 w-6 shrink-0 stroke-current"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
@@ -762,30 +718,26 @@
<!-- Adicionar Membros --> <!-- Adicionar Membros -->
<div> <div>
<h4 class="font-bold text-lg mb-3">Adicionar Membros</h4> <h4 class="mb-3 text-lg font-bold">Adicionar Membros</h4>
{#if membrosDisponiveis.length > 0} {#if membrosDisponiveis.length > 0}
<div class="space-y-2 max-h-60 overflow-y-auto"> <div class="max-h-60 space-y-2 overflow-y-auto">
{#each membrosDisponiveis as funcionario} {#each membrosDisponiveis as funcionario (funcionario._id)}
<div <div
class="flex items-center justify-between p-3 bg-base-200 rounded-lg hover:bg-base-300 transition-colors" class="bg-base-200 hover:bg-base-300 flex items-center justify-between rounded-lg p-3 transition-colors"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="avatar placeholder"> <div class="avatar placeholder">
<div <div class="bg-neutral text-neutral-content w-10 rounded-full">
class="bg-neutral text-neutral-content rounded-full w-10"
>
<span class="text-xs" <span class="text-xs"
>{funcionario.nome >{funcionario.nome.substring(0, 2).toUpperCase()}</span
.substring(0, 2)
.toUpperCase()}</span
> >
</div> </div>
</div> </div>
<div> <div>
<div class="font-semibold">{funcionario.nome}</div> <div class="font-semibold">{funcionario.nome}</div>
<div class="text-xs text-base-content/50"> <div class="text-base-content/50 text-xs">
{funcionario.matricula || "S/N"} {funcionario.matricula || 'S/N'}
</div> </div>
</div> </div>
</div> </div>
@@ -805,7 +757,7 @@
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
class="stroke-current shrink-0 w-6 h-6" class="h-6 w-6 shrink-0 stroke-current"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
@@ -824,10 +776,8 @@
</div> </div>
</div> </div>
<form method="dialog" class="modal-backdrop"> <form method="dialog" class="modal-backdrop">
<button <button type="button" onclick={fecharModalMembros} aria-label="Fechar modal"
type="button" >Fechar</button
onclick={fecharModalMembros}
aria-label="Fechar modal">Fechar</button
> >
</form> </form>
</dialog> </dialog>
@@ -837,11 +787,10 @@
{#if mostrarConfirmacaoExclusao && timeParaExcluir} {#if mostrarConfirmacaoExclusao && timeParaExcluir}
<dialog class="modal modal-open"> <dialog class="modal modal-open">
<div class="modal-box"> <div class="modal-box">
<h3 class="font-bold text-lg">Confirmar Desativação</h3> <h3 class="text-lg font-bold">Confirmar Desativação</h3>
<p class="py-4"> <p class="py-4">
Tem certeza que deseja desativar o time <strong Tem certeza que deseja desativar o time <strong>{timeParaExcluir.nome}</strong>? Todos
>{timeParaExcluir.nome}</strong os membros serão removidos.
>? Todos os membros serão removidos.
</p> </p>
<div class="modal-action"> <div class="modal-action">
<button <button
@@ -851,12 +800,8 @@
> >
Cancelar Cancelar
</button> </button>
<button <button class="btn btn-error" onclick={excluirTime} disabled={processando}>
class="btn btn-error" {processando ? 'Processando...' : 'Desativar'}
onclick={excluirTime}
disabled={processando}
>
{processando ? "Processando..." : "Desativar"}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,7 @@
import { v } from "convex/values"; import { v } from 'convex/values';
import { query } from "./_generated/server"; import { query, mutation } from './_generated/server';
import type { Id } from './_generated/dataModel';
import { getCurrentUserFunction } from './auth';
/** /**
* Listar todas as roles * Listar todas as roles
@@ -7,8 +9,8 @@ import { query } from "./_generated/server";
export const listar = query({ export const listar = query({
args: {}, args: {},
handler: async (ctx) => { handler: async (ctx) => {
return await ctx.db.query("roles").collect(); return await ctx.db.query('roles').collect();
}, }
}); });
/** /**
@@ -16,20 +18,108 @@ export const listar = query({
*/ */
export const buscarPorId = query({ export const buscarPorId = query({
args: { args: {
roleId: v.id("roles"), roleId: v.id('roles')
}, },
returns: v.union( returns: v.union(
v.object({ v.object({
_id: v.id("roles"), _id: v.id('roles'),
nome: v.string(), nome: v.string(),
descricao: v.string(), descricao: v.string(),
nivel: v.number(), nivel: v.number(),
setor: v.optional(v.string()), setor: v.optional(v.string())
}), }),
v.null() v.null()
), ),
handler: async (ctx, args) => { handler: async (ctx, args) => {
return await ctx.db.get(args.roleId); return await ctx.db.get(args.roleId);
}, }
}); });
const slugify = (value: string) =>
value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.replace(/_{2,}/g, '_');
export const criar = mutation({
args: {
nome: v.string(),
descricao: v.string(),
nivel: v.number(),
setor: v.optional(v.string()),
copiarDeRoleId: v.optional(v.id('roles'))
},
returns: v.union(
v.object({ sucesso: v.literal(true), roleId: v.id('roles') }),
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
const usuarioAtual = await getCurrentUserFunction(ctx);
if (!usuarioAtual) {
return { sucesso: false as const, erro: 'nao_autenticado' };
}
const roleAtual = await ctx.db.get(usuarioAtual.roleId);
if (!roleAtual || roleAtual.nivel > 1) {
return { sucesso: false as const, erro: 'sem_permissao' };
}
const nomeNormalizado = slugify(args.nome);
if (!nomeNormalizado) {
return { sucesso: false as const, erro: 'nome_invalido' };
}
const existente = await ctx.db
.query('roles')
.withIndex('by_nome', (q) => q.eq('nome', nomeNormalizado))
.unique();
if (existente) {
return { sucesso: false as const, erro: 'nome_ja_utilizado' };
}
let permissoesParaCopiar: Array<Id<'permissoes'>> = [];
if (args.copiarDeRoleId) {
const roleOrigem = await ctx.db.get(args.copiarDeRoleId);
if (!roleOrigem) {
return { sucesso: false as const, erro: 'role_origem_nao_encontrada' };
}
const permissoesOrigem = await ctx.db
.query('rolePermissoes')
.withIndex('by_role', (q) => q.eq('roleId', args.copiarDeRoleId!))
.collect();
permissoesParaCopiar = permissoesOrigem.map((item) => item.permissaoId);
}
const nivelAjustado = Math.min(Math.max(Math.round(args.nivel), 0), 10);
const setor = args.setor?.trim();
const roleId = await ctx.db.insert('roles', {
nome: nomeNormalizado,
descricao: args.descricao.trim() || args.nome.trim(),
nivel: nivelAjustado,
setor: setor && setor.length > 0 ? setor : undefined,
customizado: true,
criadoPor: usuarioAtual._id,
editavel: true
});
if (permissoesParaCopiar.length > 0) {
for (const permissaoId of permissoesParaCopiar) {
await ctx.db.insert('rolePermissoes', {
roleId,
permissaoId
});
}
}
return { sucesso: true as const, roleId };
}
});