feat: implement advanced access control system with user blocking, rate limiting, and enhanced login security; update UI components for improved user experience and documentation
This commit is contained in:
@@ -67,22 +67,18 @@
|
||||
|
||||
{#if getCurrentRouteConfig}
|
||||
<MenuProtection menuPath={getCurrentRouteConfig.path} requireGravar={getCurrentRouteConfig.requireGravar || false}>
|
||||
<div class="w-full h-full overflow-y-auto">
|
||||
<main
|
||||
id="container-central"
|
||||
class="w-full max-w-none px-3 lg:px-4 py-4"
|
||||
>
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
</MenuProtection>
|
||||
{:else}
|
||||
<div class="w-full h-full overflow-y-auto">
|
||||
<main
|
||||
id="container-central"
|
||||
class="w-full max-w-none px-3 lg:px-4 py-4"
|
||||
>
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
</MenuProtection>
|
||||
{:else}
|
||||
<main
|
||||
id="container-central"
|
||||
class="w-full max-w-none px-3 lg:px-4 py-4"
|
||||
>
|
||||
{@render children()}
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
@@ -105,14 +105,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Personalizar por Matrícula -->
|
||||
<!-- Card Configuração de Email -->
|
||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="p-3 bg-info/20 rounded-lg">
|
||||
<div class="p-3 bg-secondary/20 rounded-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 text-info"
|
||||
class="h-8 w-8 text-secondary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -121,18 +121,84 @@
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="card-title text-xl">Personalizar por Matrícula</h2>
|
||||
<h2 class="card-title text-xl">Configuração de Email</h2>
|
||||
</div>
|
||||
<p class="text-base-content/70 mb-4">
|
||||
Configure permissões específicas para usuários individuais por matrícula, sobrepondo as permissões da função.
|
||||
Configure o servidor SMTP para envio automático de notificações e emails do sistema.
|
||||
</p>
|
||||
<div class="card-actions justify-end">
|
||||
<a href="/ti/personalizar-permissoes" class="btn btn-info">
|
||||
Personalizar Acessos
|
||||
<a href="/ti/configuracoes-email" class="btn btn-secondary">
|
||||
Configurar SMTP
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Gerenciar Usuários -->
|
||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="p-3 bg-accent/20 rounded-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 text-accent"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="card-title text-xl">Gerenciar Usuários</h2>
|
||||
</div>
|
||||
<p class="text-base-content/70 mb-4">
|
||||
Criar, editar, bloquear e gerenciar usuários do sistema. Controle total sobre contas de acesso.
|
||||
</p>
|
||||
<div class="card-actions justify-end">
|
||||
<a href="/ti/usuarios" class="btn btn-accent">
|
||||
Gerenciar Usuários
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Gerenciar Perfis -->
|
||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="p-3 bg-warning/20 rounded-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 text-warning"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="card-title text-xl">Gerenciar Perfis</h2>
|
||||
</div>
|
||||
<p class="text-base-content/70 mb-4">
|
||||
Crie e gerencie perfis de acesso personalizados com permissões específicas para grupos de usuários.
|
||||
</p>
|
||||
<div class="card-actions justify-end">
|
||||
<a href="/ti/perfis" class="btn btn-warning">
|
||||
Gerenciar Perfis
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
224
apps/web/src/routes/(dashboard)/ti/auditoria/+page.svelte
Normal file
224
apps/web/src/routes/(dashboard)/ti/auditoria/+page.svelte
Normal file
@@ -0,0 +1,224 @@
|
||||
<script lang="ts">
|
||||
import { useQuery } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
|
||||
let abaAtiva = $state<"atividades" | "logins">("atividades");
|
||||
let limite = $state(50);
|
||||
|
||||
// Queries
|
||||
const atividades = useQuery(api.logsAtividades.listarAtividades, { limite });
|
||||
const logins = useQuery(api.logsLogin.listarTodosLogins, { limite });
|
||||
|
||||
function formatarData(timestamp: number) {
|
||||
return new Date(timestamp).toLocaleString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function getAcaoColor(acao: string) {
|
||||
const colors: Record<string, string> = {
|
||||
criar: "badge-success",
|
||||
editar: "badge-warning",
|
||||
excluir: "badge-error",
|
||||
bloquear: "badge-error",
|
||||
desbloquear: "badge-success",
|
||||
resetar_senha: "badge-info"
|
||||
};
|
||||
return colors[acao] || "badge-neutral";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto px-4 py-6 max-w-7xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-accent/10 rounded-xl">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-accent" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-base-content">Auditoria e Logs</h1>
|
||||
<p class="text-base-content/60 mt-1">Histórico completo de atividades e acessos</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs tabs-boxed mb-6 bg-base-100 shadow-lg p-2">
|
||||
<button
|
||||
class="tab {abaAtiva === 'atividades' ? 'tab-active' : ''}"
|
||||
onclick={() => abaAtiva = "atividades"}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
Atividades no Sistema
|
||||
</button>
|
||||
<button
|
||||
class="tab {abaAtiva === 'logins' ? 'tab-active' : ''}"
|
||||
onclick={() => abaAtiva = "logins"}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Histórico de Logins
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Controles -->
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Quantidade de registros</span>
|
||||
</label>
|
||||
<select bind:value={limite} class="select select-bordered">
|
||||
<option value={20}>20 registros</option>
|
||||
<option value={50}>50 registros</option>
|
||||
<option value={100}>100 registros</option>
|
||||
<option value={200}>200 registros</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-outline btn-primary">
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Exportar CSV
|
||||
</button>
|
||||
<button class="btn btn-outline btn-secondary">
|
||||
<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="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||
</svg>
|
||||
Filtros Avançados
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
{#if abaAtiva === "atividades"}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">Atividades Recentes</h2>
|
||||
|
||||
{#if !atividades?.data}
|
||||
<div class="flex justify-center py-10">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
{:else if atividades.data.length === 0}
|
||||
<div class="text-center py-10 text-base-content/60">
|
||||
Nenhuma atividade registrada
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Data/Hora</th>
|
||||
<th>Usuário</th>
|
||||
<th>Ação</th>
|
||||
<th>Recurso</th>
|
||||
<th>Detalhes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each atividades.data as atividade}
|
||||
<tr class="hover">
|
||||
<td class="font-mono text-xs">{formatarData(atividade.timestamp)}</td>
|
||||
<td>
|
||||
<div class="font-medium">{atividade.usuarioNome || "Sistema"}</div>
|
||||
<div class="text-xs opacity-60">{atividade.usuarioMatricula || "-"}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge {getAcaoColor(atividade.acao)} badge-sm">
|
||||
{atividade.acao}
|
||||
</span>
|
||||
</td>
|
||||
<td class="font-medium">{atividade.recurso}</td>
|
||||
<td>
|
||||
<div class="text-xs max-w-md truncate" title={atividade.detalhes}>
|
||||
{atividade.detalhes || "-"}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">Histórico de Logins</h2>
|
||||
|
||||
{#if !logins?.data}
|
||||
<div class="flex justify-center py-10">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
{:else if logins.data.length === 0}
|
||||
<div class="text-center py-10 text-base-content/60">
|
||||
Nenhum login registrado
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Data/Hora</th>
|
||||
<th>Usuário/Email</th>
|
||||
<th>Status</th>
|
||||
<th>IP</th>
|
||||
<th>Dispositivo</th>
|
||||
<th>Navegador</th>
|
||||
<th>Sistema</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each logins.data as login}
|
||||
<tr class="hover">
|
||||
<td class="font-mono text-xs">{formatarData(login.timestamp)}</td>
|
||||
<td class="text-sm">{login.matriculaOuEmail}</td>
|
||||
<td>
|
||||
{#if login.sucesso}
|
||||
<span class="badge badge-success badge-sm">Sucesso</span>
|
||||
{:else}
|
||||
<span class="badge badge-error badge-sm">Falhou</span>
|
||||
{#if login.motivoFalha}
|
||||
<div class="text-xs text-error mt-1">{login.motivoFalha}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="font-mono text-xs">{login.ipAddress || "-"}</td>
|
||||
<td class="text-xs">{login.device || "-"}</td>
|
||||
<td class="text-xs">{login.browser || "-"}</td>
|
||||
<td class="text-xs">{login.sistema || "-"}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Informação -->
|
||||
<div class="alert alert-info mt-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<span>Os logs são armazenados permanentemente e não podem ser alterados ou excluídos.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import { authStore } from "$lib/stores/auth.svelte";
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
|
||||
const client = useConvexClient();
|
||||
const configAtual = useQuery(api.configuracaoEmail.obterConfigEmail, {});
|
||||
|
||||
let servidor = $state("");
|
||||
let porta = $state(587);
|
||||
let usuario = $state("");
|
||||
let senha = $state("");
|
||||
let emailRemetente = $state("");
|
||||
let nomeRemetente = $state("");
|
||||
let usarSSL = $state(false);
|
||||
let usarTLS = $state(true);
|
||||
let processando = $state(false);
|
||||
let testando = $state(false);
|
||||
let mensagem = $state<{ tipo: "success" | "error"; texto: string } | null>(null);
|
||||
|
||||
function mostrarMensagem(tipo: "success" | "error", texto: string) {
|
||||
mensagem = { tipo, texto };
|
||||
setTimeout(() => {
|
||||
mensagem = null;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Carregar config existente
|
||||
$effect(() => {
|
||||
if (configAtual?.data) {
|
||||
servidor = configAtual.data.servidor || "";
|
||||
porta = configAtual.data.porta || 587;
|
||||
usuario = configAtual.data.usuario || "";
|
||||
emailRemetente = configAtual.data.emailRemetente || "";
|
||||
nomeRemetente = configAtual.data.nomeRemetente || "";
|
||||
usarSSL = configAtual.data.usarSSL || false;
|
||||
usarTLS = configAtual.data.usarTLS || true;
|
||||
}
|
||||
});
|
||||
|
||||
async function salvarConfiguracao() {
|
||||
if (!servidor || !porta || !usuario || !senha || !emailRemetente) {
|
||||
mostrarMensagem("error", "Preencha todos os campos obrigatórios");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authStore.usuario) {
|
||||
mostrarMensagem("error", "Usuário não autenticado");
|
||||
return;
|
||||
}
|
||||
|
||||
processando = true;
|
||||
try {
|
||||
const resultado = await client.mutation(api.configuracaoEmail.salvarConfigEmail, {
|
||||
servidor: servidor.trim(),
|
||||
porta: Number(porta),
|
||||
usuario: usuario.trim(),
|
||||
senha: senha,
|
||||
emailRemetente: emailRemetente.trim(),
|
||||
nomeRemetente: nomeRemetente.trim(),
|
||||
usarSSL,
|
||||
usarTLS,
|
||||
configuradoPorId: authStore.usuario._id as Id<"usuarios">
|
||||
});
|
||||
|
||||
if (resultado.sucesso) {
|
||||
mostrarMensagem("success", "Configuração salva com sucesso!");
|
||||
senha = ""; // Limpar senha
|
||||
} else {
|
||||
mostrarMensagem("error", resultado.erro);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Erro ao salvar configuração:", error);
|
||||
mostrarMensagem("error", error.message || "Erro ao salvar configuração");
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testarConexao() {
|
||||
if (!servidor || !porta || !usuario || !senha) {
|
||||
mostrarMensagem("error", "Preencha os dados de conexão antes de testar");
|
||||
return;
|
||||
}
|
||||
|
||||
testando = true;
|
||||
try {
|
||||
const resultado = await client.action(api.configuracaoEmail.testarConexaoSMTP, {
|
||||
servidor: servidor.trim(),
|
||||
porta: Number(porta),
|
||||
usuario: usuario.trim(),
|
||||
senha: senha,
|
||||
usarSSL,
|
||||
usarTLS,
|
||||
});
|
||||
|
||||
if (resultado.sucesso) {
|
||||
mostrarMensagem("success", "Conexão testada com sucesso! Servidor SMTP está respondendo.");
|
||||
} else {
|
||||
mostrarMensagem("error", `Erro ao testar conexão: ${resultado.erro}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Erro ao testar conexão:", error);
|
||||
mostrarMensagem("error", error.message || "Erro ao conectar com o servidor SMTP");
|
||||
} finally {
|
||||
testando = false;
|
||||
}
|
||||
}
|
||||
|
||||
const statusConfig = $derived(
|
||||
configAtual?.data?.ativo ? "Configurado" : "Não configurado"
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto px-4 py-6 max-w-4xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-secondary/10 rounded-xl">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-base-content">Configurações de Email (SMTP)</h1>
|
||||
<p class="text-base-content/60 mt-1">Configurar servidor de email para envio de notificações</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mensagens -->
|
||||
{#if mensagem}
|
||||
<div
|
||||
class="alert mb-6"
|
||||
class:alert-success={mensagem.tipo === "success"}
|
||||
class:alert-error={mensagem.tipo === "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"
|
||||
>
|
||||
{#if mensagem.tipo === "success"}
|
||||
<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"
|
||||
/>
|
||||
{:else}
|
||||
<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"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
<span>{mensagem.texto}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Status -->
|
||||
<div class="alert {configAtual?.data?.ativo ? 'alert-success' : 'alert-warning'} mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
|
||||
{#if configAtual?.data?.ativo}
|
||||
<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" />
|
||||
{:else}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
{/if}
|
||||
</svg>
|
||||
<span>
|
||||
<strong>Status:</strong> {statusConfig}
|
||||
{#if configAtual?.data?.testadoEm}
|
||||
- Última conexão testada em {new Date(configAtual.data.testadoEm).toLocaleString('pt-BR')}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Formulário -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">Dados do Servidor SMTP</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Servidor -->
|
||||
<div class="form-control md:col-span-1">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Servidor SMTP *</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={servidor}
|
||||
placeholder="smtp.exemplo.com"
|
||||
class="input input-bordered"
|
||||
/>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Ex: smtp.gmail.com, smtp.office365.com</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Porta -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Porta *</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={porta}
|
||||
placeholder="587"
|
||||
class="input input-bordered"
|
||||
/>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Comum: 587 (TLS), 465 (SSL), 25</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Usuário -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Usuário/Email *</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={usuario}
|
||||
placeholder="usuario@exemplo.com"
|
||||
class="input input-bordered"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Senha -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Senha *</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={senha}
|
||||
placeholder="••••••••"
|
||||
class="input input-bordered"
|
||||
/>
|
||||
<label class="label">
|
||||
<span class="label-text-alt text-warning">
|
||||
{#if configAtual?.data?.ativo}
|
||||
Deixe em branco para manter a senha atual
|
||||
{:else}
|
||||
Digite a senha da conta de email
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Email Remetente -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Email Remetente *</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
bind:value={emailRemetente}
|
||||
placeholder="noreply@sgse.pe.gov.br"
|
||||
class="input input-bordered"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Nome Remetente -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Nome Remetente *</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={nomeRemetente}
|
||||
placeholder="SGSE - Sistema de Gestão"
|
||||
class="input input-bordered"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Opções de Segurança -->
|
||||
<div class="divider"></div>
|
||||
<h3 class="font-bold mb-2">Configurações de Segurança</h3>
|
||||
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={usarSSL}
|
||||
class="checkbox checkbox-primary"
|
||||
/>
|
||||
<span class="label-text">Usar SSL (porta 465)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={usarTLS}
|
||||
class="checkbox checkbox-primary"
|
||||
/>
|
||||
<span class="label-text">Usar TLS (porta 587)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="card-actions justify-end mt-6 gap-3">
|
||||
<button
|
||||
class="btn btn-outline btn-info"
|
||||
onclick={testarConexao}
|
||||
disabled={testando || processando}
|
||||
>
|
||||
{#if testando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{/if}
|
||||
Testar Conexão
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={salvarConfiguracao}
|
||||
disabled={processando || testando}
|
||||
>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<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="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
||||
</svg>
|
||||
{/if}
|
||||
Salvar Configuração
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Exemplos Comuns -->
|
||||
<div class="card bg-base-100 shadow-xl mt-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">Exemplos de Configuração</h2>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Provedor</th>
|
||||
<th>Servidor</th>
|
||||
<th>Porta</th>
|
||||
<th>Segurança</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Gmail</strong></td>
|
||||
<td>smtp.gmail.com</td>
|
||||
<td>587</td>
|
||||
<td>TLS</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Outlook/Office365</strong></td>
|
||||
<td>smtp.office365.com</td>
|
||||
<td>587</td>
|
||||
<td>TLS</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Yahoo</strong></td>
|
||||
<td>smtp.mail.yahoo.com</td>
|
||||
<td>465</td>
|
||||
<td>SSL</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>SendGrid</strong></td>
|
||||
<td>smtp.sendgrid.net</td>
|
||||
<td>587</td>
|
||||
<td>TLS</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Avisos -->
|
||||
<div class="alert alert-info mt-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p><strong>Dica de Segurança:</strong> Para Gmail e outros provedores, você pode precisar gerar uma "senha de app" específica em vez de usar sua senha principal.</p>
|
||||
<p class="text-sm mt-1">Gmail: Conta Google → Segurança → Verificação em duas etapas → Senhas de app</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
299
apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte
Normal file
299
apps/web/src/routes/(dashboard)/ti/notificacoes/+page.svelte
Normal file
@@ -0,0 +1,299 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
|
||||
const client = useConvexClient();
|
||||
const templates = useQuery(api.templatesMensagens.listarTemplates, {});
|
||||
const usuarios = useQuery(api.usuarios.listar, {});
|
||||
|
||||
let destinatarioId = $state("");
|
||||
let canal = $state<"chat" | "email" | "ambos">("chat");
|
||||
let templateId = $state("");
|
||||
let mensagemPersonalizada = $state("");
|
||||
let usarTemplate = $state(true);
|
||||
let processando = $state(false);
|
||||
|
||||
const templateSelecionado = $derived(
|
||||
templates?.data?.find(t => t._id === templateId)
|
||||
);
|
||||
|
||||
async function enviarNotificacao() {
|
||||
if (!destinatarioId) {
|
||||
alert("Selecione um destinatário");
|
||||
return;
|
||||
}
|
||||
|
||||
if (usarTemplate && !templateId) {
|
||||
alert("Selecione um template");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!usarTemplate && !mensagemPersonalizada.trim()) {
|
||||
alert("Digite uma mensagem");
|
||||
return;
|
||||
}
|
||||
|
||||
processando = true;
|
||||
try {
|
||||
// TODO: Implementar envio de notificação
|
||||
console.log("Enviar notificação", {
|
||||
destinatarioId,
|
||||
canal,
|
||||
templateId: usarTemplate ? templateId : undefined,
|
||||
mensagem: !usarTemplate ? mensagemPersonalizada : undefined
|
||||
});
|
||||
|
||||
alert("Notificação enviada com sucesso!");
|
||||
|
||||
// Limpar form
|
||||
destinatarioId = "";
|
||||
templateId = "";
|
||||
mensagemPersonalizada = "";
|
||||
} catch (error) {
|
||||
console.error("Erro ao enviar notificação:", error);
|
||||
alert("Erro ao enviar notificação");
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto px-4 py-6 max-w-7xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-info/10 rounded-xl">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-info" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-base-content">Notificações e Mensagens</h1>
|
||||
<p class="text-base-content/60 mt-1">Enviar notificações para usuários do sistema</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Formulário -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">Enviar Notificação</h2>
|
||||
|
||||
<!-- Destinatário -->
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Destinatário *</span>
|
||||
</label>
|
||||
<select bind:value={destinatarioId} class="select select-bordered">
|
||||
<option value="">Selecione um usuário</option>
|
||||
{#if usuarios?.data}
|
||||
{#each usuarios.data as usuario}
|
||||
<option value={usuario._id}>
|
||||
{usuario.nome} ({usuario.matricula})
|
||||
</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Canal -->
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Canal de Envio *</span>
|
||||
</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="label cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value="chat"
|
||||
bind:group={canal}
|
||||
class="radio radio-primary"
|
||||
/>
|
||||
<span class="label-text ml-2">Chat</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value="email"
|
||||
bind:group={canal}
|
||||
class="radio radio-primary"
|
||||
/>
|
||||
<span class="label-text ml-2">Email</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value="ambos"
|
||||
bind:group={canal}
|
||||
class="radio radio-primary"
|
||||
/>
|
||||
<span class="label-text ml-2">Ambos</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tipo de Mensagem -->
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Tipo de Mensagem</span>
|
||||
</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="label cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
checked={usarTemplate}
|
||||
onchange={() => usarTemplate = true}
|
||||
class="radio radio-secondary"
|
||||
/>
|
||||
<span class="label-text ml-2">Usar Template</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
checked={!usarTemplate}
|
||||
onchange={() => usarTemplate = false}
|
||||
class="radio radio-secondary"
|
||||
/>
|
||||
<span class="label-text ml-2">Mensagem Personalizada</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if usarTemplate}
|
||||
<!-- Template -->
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Template *</span>
|
||||
</label>
|
||||
<select bind:value={templateId} class="select select-bordered">
|
||||
<option value="">Selecione um template</option>
|
||||
{#if templates?.data}
|
||||
{#each templates.data as template}
|
||||
<option value={template._id}>
|
||||
{template.nome}
|
||||
</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if templateSelecionado}
|
||||
<div class="alert alert-info mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<div class="font-bold">{templateSelecionado.titulo}</div>
|
||||
<div class="text-sm mt-1">{templateSelecionado.corpo}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Mensagem Personalizada -->
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Mensagem *</span>
|
||||
</label>
|
||||
<textarea
|
||||
bind:value={mensagemPersonalizada}
|
||||
class="textarea textarea-bordered h-32"
|
||||
placeholder="Digite sua mensagem personalizada..."
|
||||
></textarea>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Botão Enviar -->
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<button
|
||||
class="btn btn-primary btn-block"
|
||||
onclick={enviarNotificacao}
|
||||
disabled={processando}
|
||||
>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<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 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
{/if}
|
||||
Enviar Notificação
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de Templates -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="card-title">Templates Disponíveis</h2>
|
||||
<button class="btn btn-sm btn-outline btn-primary">
|
||||
<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="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Novo Template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if !templates?.data}
|
||||
<div class="flex justify-center py-10">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
{:else if templates.data.length === 0}
|
||||
<div class="text-center py-10 text-base-content/60">
|
||||
Nenhum template disponível
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3 max-h-[600px] overflow-y-auto">
|
||||
{#each templates.data as template}
|
||||
<div class="card bg-base-200 compact">
|
||||
<div class="card-body">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="font-bold text-sm">{template.nome}</h3>
|
||||
<p class="text-xs opacity-70 mt-1">{template.titulo}</p>
|
||||
<p class="text-xs mt-2 line-clamp-2">{template.corpo}</p>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<span class="badge badge-sm {template.tipo === 'sistema' ? 'badge-primary' : 'badge-secondary'}">
|
||||
{template.tipo}
|
||||
</span>
|
||||
{#if template.variaveis && template.variaveis.length > 0}
|
||||
<span class="badge badge-sm badge-outline">
|
||||
{template.variaveis.length} variáveis
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if template.tipo !== "sistema"}
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost btn-xs">
|
||||
<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="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||
</svg>
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box w-32">
|
||||
<li><button>Editar</button></li>
|
||||
<li><button class="text-error">Excluir</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="alert alert-warning mt-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
<span>Para enviar emails, certifique-se de configurar o SMTP em Configurações de Email.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,40 @@
|
||||
|
||||
let salvando = $state(false);
|
||||
let mensagem = $state<{ tipo: "success" | "error"; texto: string } | null>(null);
|
||||
let busca = $state("");
|
||||
let filtroRole = $state("");
|
||||
|
||||
function mostrarMensagem(tipo: "success" | "error", texto: string) {
|
||||
mensagem = { tipo, texto };
|
||||
setTimeout(() => {
|
||||
mensagem = null;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
const dadosFiltrados = $derived.by(() => {
|
||||
if (!matrizQuery.data) return [];
|
||||
|
||||
let resultado = matrizQuery.data;
|
||||
|
||||
// Filtrar por role
|
||||
if (filtroRole) {
|
||||
resultado = resultado.filter(r => r.role._id === filtroRole);
|
||||
}
|
||||
|
||||
// Filtrar por busca
|
||||
if (busca.trim()) {
|
||||
const buscaLower = busca.toLowerCase();
|
||||
resultado = resultado.map(roleData => ({
|
||||
...roleData,
|
||||
permissoes: roleData.permissoes.filter(p =>
|
||||
p.menuNome.toLowerCase().includes(buscaLower) ||
|
||||
p.menuPath.toLowerCase().includes(buscaLower)
|
||||
)
|
||||
})).filter(roleData => roleData.permissoes.length > 0);
|
||||
}
|
||||
|
||||
return resultado;
|
||||
});
|
||||
|
||||
async function atualizarPermissao(
|
||||
roleId: Id<"roles">,
|
||||
@@ -71,12 +105,9 @@
|
||||
podeGravar,
|
||||
});
|
||||
|
||||
mensagem = { tipo: "success", texto: "Permissão atualizada com sucesso!" };
|
||||
setTimeout(() => {
|
||||
mensagem = null;
|
||||
}, 3000);
|
||||
mostrarMensagem("success", "Permissão atualizada com sucesso!");
|
||||
} catch (e: any) {
|
||||
mensagem = { tipo: "error", texto: e.message || "Erro ao atualizar permissão" };
|
||||
mostrarMensagem("error", e.message || "Erro ao atualizar permissão");
|
||||
} finally {
|
||||
salvando = false;
|
||||
}
|
||||
@@ -86,19 +117,16 @@
|
||||
try {
|
||||
salvando = true;
|
||||
await client.mutation(api.menuPermissoes.inicializarPermissoesRole, { roleId });
|
||||
mensagem = { tipo: "success", texto: "Permissões inicializadas!" };
|
||||
setTimeout(() => {
|
||||
mensagem = null;
|
||||
}, 3000);
|
||||
mostrarMensagem("success", "Permissões inicializadas!");
|
||||
} catch (e: any) {
|
||||
mensagem = { tipo: "error", texto: e.message || "Erro ao inicializar permissões" };
|
||||
mostrarMensagem("error", e.message || "Erro ao inicializar permissões");
|
||||
} finally {
|
||||
salvando = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ProtectedRoute allowedRoles={["admin", "ti"]} maxLevel={1}>
|
||||
<ProtectedRoute allowedRoles={["ti_master", "admin"]} maxLevel={1}>
|
||||
<!-- Breadcrumb -->
|
||||
<div class="text-sm breadcrumbs mb-4">
|
||||
<ul>
|
||||
@@ -154,20 +182,119 @@
|
||||
</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 Menu</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="busca"
|
||||
type="text"
|
||||
placeholder="Digite o nome ou caminho do menu..."
|
||||
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 matrizQuery.data}
|
||||
{#each matrizQuery.data as roleData}
|
||||
<option value={roleData.role._id}>
|
||||
{roleData.role.descricao} ({roleData.role.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">
|
||||
<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">Como funciona:</h3>
|
||||
<ul class="text-sm mt-2 space-y-1">
|
||||
<li>• <strong>Acessar:</strong> Permite visualizar o menu e entrar na página</li>
|
||||
<li>• <strong>Consultar:</strong> Permite visualizar dados (requer "Acessar")</li>
|
||||
<li>• <strong>Gravar:</strong> Permite criar, editar e excluir dados (requer "Consultar")</li>
|
||||
<li>• <strong>Admin e TI:</strong> Têm acesso total automático a todos os recursos</li>
|
||||
<li>• <strong>Dashboard e Solicitar Acesso:</strong> São públicos para todos os usuários</li>
|
||||
</ul>
|
||||
<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>
|
||||
|
||||
@@ -184,19 +311,60 @@
|
||||
<span>Erro ao carregar permissões: {matrizQuery.error.message}</span>
|
||||
</div>
|
||||
{:else if matrizQuery.data}
|
||||
{#each matrizQuery.data as roleData}
|
||||
{#if dadosFiltrados.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 menus com "${busca}"` : "Nenhuma permissão corresponde aos filtros aplicados"}
|
||||
</p>
|
||||
<button
|
||||
class="btn btn-primary btn-sm mt-4"
|
||||
onclick={() => {
|
||||
busca = "";
|
||||
filtroRole = "";
|
||||
}}
|
||||
>
|
||||
Limpar Filtros
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each dadosFiltrados as roleData}
|
||||
<div class="card bg-base-100 shadow-xl mb-6">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="card-title text-xl">
|
||||
{roleData.role.nome}
|
||||
<div class="badge badge-primary">Nível {roleData.role.nivel}</div>
|
||||
<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">{roleData.role.descricao}</h2>
|
||||
<div class="badge badge-lg badge-primary">Nível {roleData.role.nivel}</div>
|
||||
{#if roleData.role.nivel <= 1}
|
||||
<div class="badge badge-success">Acesso Total</div>
|
||||
<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}
|
||||
</h2>
|
||||
<p class="text-sm text-base-content/60 mt-1">{roleData.role.descricao}</p>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/60">
|
||||
<span class="font-mono bg-base-200 px-2 py-1 rounded">{roleData.role.nome}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if roleData.role.nivel > 1}
|
||||
@@ -214,13 +382,35 @@
|
||||
</div>
|
||||
|
||||
{#if roleData.role.nivel <= 1}
|
||||
<div class="alert alert-success">
|
||||
<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>
|
||||
<span>Esta função possui acesso total ao sistema automaticamente.</span>
|
||||
<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}
|
||||
<div class="stats stats-vertical lg:stats-horizontal shadow mb-4 w-full">
|
||||
<div class="stat">
|
||||
<div class="stat-title">Total de Menus</div>
|
||||
<div class="stat-value text-primary">{roleData.permissoes.length}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Com Acesso</div>
|
||||
<div class="stat-value text-info">{roleData.permissoes.filter(p => p.podeAcessar).length}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Pode Consultar</div>
|
||||
<div class="stat-value text-success">{roleData.permissoes.filter(p => p.podeConsultar).length}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-title">Pode Gravar</div>
|
||||
<div class="stat-value text-warning">{roleData.permissoes.filter(p => p.podeGravar).length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra table-sm">
|
||||
<thead class="bg-base-200">
|
||||
|
||||
941
apps/web/src/routes/(dashboard)/ti/perfis/+page.svelte
Normal file
941
apps/web/src/routes/(dashboard)/ti/perfis/+page.svelte
Normal file
@@ -0,0 +1,941 @@
|
||||
<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 { authStore } from "$lib/stores/auth.svelte";
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
// Queries
|
||||
const perfisQuery = useQuery(api.perfisCustomizados.listarPerfisCustomizados, {});
|
||||
const rolesQuery = useQuery(api.roles.listar, {});
|
||||
|
||||
// Estados
|
||||
let modo = $state<"listar" | "criar" | "editar" | "detalhes">("listar");
|
||||
let perfilSelecionado = $state<any>(null);
|
||||
let processando = $state(false);
|
||||
let mensagem = $state<{ tipo: "success" | "error" | "warning"; texto: string } | null>(null);
|
||||
let modalExcluir = $state(false);
|
||||
let perfilParaExcluir = $state<any>(null);
|
||||
|
||||
// Formulário
|
||||
let formNome = $state("");
|
||||
let formDescricao = $state("");
|
||||
let formNivel = $state(3);
|
||||
let formClonarDeRoleId = $state<string>("");
|
||||
|
||||
// Detalhes do perfil
|
||||
let detalhesQuery = $state<any>(null);
|
||||
|
||||
function mostrarMensagem(tipo: "success" | "error" | "warning", texto: string) {
|
||||
mensagem = { tipo, texto };
|
||||
setTimeout(() => {
|
||||
mensagem = null;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function abrirCriar() {
|
||||
modo = "criar";
|
||||
formNome = "";
|
||||
formDescricao = "";
|
||||
formNivel = 3;
|
||||
formClonarDeRoleId = "";
|
||||
}
|
||||
|
||||
function abrirEditar(perfil: any) {
|
||||
modo = "editar";
|
||||
perfilSelecionado = perfil;
|
||||
formNome = perfil.nome;
|
||||
formDescricao = perfil.descricao;
|
||||
formNivel = perfil.nivel;
|
||||
}
|
||||
|
||||
async function abrirDetalhes(perfil: any) {
|
||||
modo = "detalhes";
|
||||
perfilSelecionado = perfil;
|
||||
|
||||
// Buscar detalhes completos
|
||||
try {
|
||||
const detalhes = await client.query(api.perfisCustomizados.obterPerfilComPermissoes, {
|
||||
perfilId: perfil._id,
|
||||
});
|
||||
detalhesQuery = detalhes;
|
||||
} catch (e: any) {
|
||||
mostrarMensagem("error", e.message || "Erro ao carregar detalhes");
|
||||
}
|
||||
}
|
||||
|
||||
function voltar() {
|
||||
modo = "listar";
|
||||
perfilSelecionado = null;
|
||||
detalhesQuery = null;
|
||||
}
|
||||
|
||||
async function criarPerfil() {
|
||||
if (!formNome.trim() || !formDescricao.trim()) {
|
||||
mostrarMensagem("warning", "Preencha todos os campos obrigatórios");
|
||||
return;
|
||||
}
|
||||
|
||||
if (formNivel < 3) {
|
||||
mostrarMensagem("warning", "O nível mínimo para perfis customizados é 3");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authStore.usuario) {
|
||||
mostrarMensagem("error", "Usuário não autenticado");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
processando = true;
|
||||
|
||||
const resultado = await client.mutation(api.perfisCustomizados.criarPerfilCustomizado, {
|
||||
nome: formNome.trim(),
|
||||
descricao: formDescricao.trim(),
|
||||
nivel: formNivel,
|
||||
clonarDeRoleId: formClonarDeRoleId ? (formClonarDeRoleId as Id<"roles">) : undefined,
|
||||
criadoPorId: authStore.usuario._id as Id<"usuarios">,
|
||||
});
|
||||
|
||||
if (resultado.sucesso) {
|
||||
mostrarMensagem("success", "Perfil criado com sucesso!");
|
||||
voltar();
|
||||
} else {
|
||||
mostrarMensagem("error", resultado.erro);
|
||||
}
|
||||
} catch (e: any) {
|
||||
mostrarMensagem("error", e.message || "Erro ao criar perfil");
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function editarPerfil() {
|
||||
if (!perfilSelecionado) return;
|
||||
|
||||
if (!formNome.trim() || !formDescricao.trim()) {
|
||||
mostrarMensagem("warning", "Preencha todos os campos obrigatórios");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authStore.usuario) {
|
||||
mostrarMensagem("error", "Usuário não autenticado");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
processando = true;
|
||||
|
||||
const resultado = await client.mutation(api.perfisCustomizados.editarPerfilCustomizado, {
|
||||
perfilId: perfilSelecionado._id,
|
||||
nome: formNome.trim(),
|
||||
descricao: formDescricao.trim(),
|
||||
editadoPorId: authStore.usuario._id as Id<"usuarios">,
|
||||
});
|
||||
|
||||
if (resultado.sucesso) {
|
||||
mostrarMensagem("success", "Perfil atualizado com sucesso!");
|
||||
voltar();
|
||||
} else {
|
||||
mostrarMensagem("error", resultado.erro);
|
||||
}
|
||||
} catch (e: any) {
|
||||
mostrarMensagem("error", e.message || "Erro ao editar perfil");
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
function abrirModalExcluir(perfil: any) {
|
||||
perfilParaExcluir = perfil;
|
||||
modalExcluir = true;
|
||||
}
|
||||
|
||||
function fecharModalExcluir() {
|
||||
modalExcluir = false;
|
||||
perfilParaExcluir = null;
|
||||
}
|
||||
|
||||
async function confirmarExclusao() {
|
||||
if (!perfilParaExcluir || !authStore.usuario) {
|
||||
mostrarMensagem("error", "Erro ao excluir perfil");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
processando = true;
|
||||
modalExcluir = false;
|
||||
|
||||
const resultado = await client.mutation(api.perfisCustomizados.excluirPerfilCustomizado, {
|
||||
perfilId: perfilParaExcluir._id,
|
||||
excluidoPorId: authStore.usuario._id as Id<"usuarios">,
|
||||
});
|
||||
|
||||
if (resultado.sucesso) {
|
||||
mostrarMensagem("success", "Perfil excluído com sucesso!");
|
||||
} else {
|
||||
mostrarMensagem("error", resultado.erro);
|
||||
}
|
||||
} catch (e: any) {
|
||||
mostrarMensagem("error", e.message || "Erro ao excluir perfil");
|
||||
} finally {
|
||||
processando = false;
|
||||
perfilParaExcluir = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function clonarPerfil(perfil: any) {
|
||||
const novoNome = prompt(`Digite o nome para o novo perfil (clone de "${perfil.nome}"):`);
|
||||
if (!novoNome?.trim()) return;
|
||||
|
||||
const novaDescricao = prompt("Digite a descrição para o novo perfil:");
|
||||
if (!novaDescricao?.trim()) return;
|
||||
|
||||
if (!authStore.usuario) {
|
||||
mostrarMensagem("error", "Usuário não autenticado");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
processando = true;
|
||||
|
||||
const resultado = await client.mutation(api.perfisCustomizados.clonarPerfil, {
|
||||
perfilOrigemId: perfil._id,
|
||||
novoNome: novoNome.trim(),
|
||||
novaDescricao: novaDescricao.trim(),
|
||||
criadoPorId: authStore.usuario._id as Id<"usuarios">,
|
||||
});
|
||||
|
||||
if (resultado.sucesso) {
|
||||
mostrarMensagem("success", "Perfil clonado com sucesso!");
|
||||
} else {
|
||||
mostrarMensagem("error", resultado.erro);
|
||||
}
|
||||
} catch (e: any) {
|
||||
mostrarMensagem("error", e.message || "Erro ao clonar perfil");
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatarData(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleString("pt-BR");
|
||||
}
|
||||
</script>
|
||||
|
||||
<ProtectedRoute allowedRoles={["ti_master", "admin"]} maxLevel={1}>
|
||||
<div class="container mx-auto px-4 py-6 max-w-7xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-secondary/10 rounded-xl">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 text-secondary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-base-content">Gerenciar Perfis Customizados</h1>
|
||||
<p class="text-base-content/60 mt-1">
|
||||
Crie e gerencie perfis de acesso personalizados para os usuários
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
{#if modo !== "listar"}
|
||||
<button class="btn btn-ghost gap-2" onclick={voltar} disabled={processando}>
|
||||
<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>
|
||||
{/if}
|
||||
{#if modo === "listar"}
|
||||
<a href="/ti" class="btn btn-outline btn-primary gap-2">
|
||||
<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="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>
|
||||
Voltar para TI
|
||||
</a>
|
||||
<button class="btn btn-primary gap-2" onclick={abrirCriar} disabled={processando}>
|
||||
<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>
|
||||
Novo Perfil
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mensagens -->
|
||||
{#if mensagem}
|
||||
<div
|
||||
class="alert mb-6"
|
||||
class:alert-success={mensagem.tipo === "success"}
|
||||
class:alert-error={mensagem.tipo === "error"}
|
||||
class:alert-warning={mensagem.tipo === "warning"}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="stroke-current shrink-0 h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{#if mensagem.tipo === "success"}
|
||||
<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"
|
||||
/>
|
||||
{:else if mensagem.tipo === "error"}
|
||||
<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"
|
||||
/>
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
<span>{mensagem.texto}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Modo: Listar -->
|
||||
{#if modo === "listar"}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
{#if !perfisQuery}
|
||||
<div class="flex justify-center items-center py-20">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
{:else if perfisQuery.data && perfisQuery.data.length === 0}
|
||||
<div class="text-center py-20">
|
||||
<div class="text-6xl mb-4">📋</div>
|
||||
<h3 class="text-2xl font-bold mb-2">Nenhum perfil customizado</h3>
|
||||
<p class="text-base-content/60 mb-6">
|
||||
Crie seu primeiro perfil personalizado clicando no botão acima
|
||||
</p>
|
||||
</div>
|
||||
{:else if perfisQuery.data}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nome</th>
|
||||
<th>Descrição</th>
|
||||
<th>Nível</th>
|
||||
<th>Usuários</th>
|
||||
<th>Criado Por</th>
|
||||
<th>Criado Em</th>
|
||||
<th class="text-right">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each perfisQuery.data as perfil}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="font-bold">{perfil.nome}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-sm opacity-70 max-w-xs truncate">
|
||||
{perfil.descricao}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="badge badge-primary">{perfil.nivel}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="badge badge-ghost">
|
||||
{perfil.numeroUsuarios} usuário{perfil.numeroUsuarios !== 1 ? "s" : ""}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-sm">{perfil.criadorNome}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-sm">{formatarData(perfil.criadoEm)}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button
|
||||
class="btn btn-sm btn-info btn-square tooltip"
|
||||
data-tip="Ver Detalhes"
|
||||
onclick={() => abrirDetalhes(perfil)}
|
||||
disabled={processando}
|
||||
>
|
||||
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-warning btn-square tooltip"
|
||||
data-tip="Editar"
|
||||
onclick={() => abrirEditar(perfil)}
|
||||
disabled={processando}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-success btn-square tooltip"
|
||||
data-tip="Clonar"
|
||||
onclick={() => clonarPerfil(perfil)}
|
||||
disabled={processando}
|
||||
>
|
||||
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-error btn-square tooltip"
|
||||
data-tip={perfil.numeroUsuarios > 0 ? "Não pode excluir - Perfil em uso" : "Excluir"}
|
||||
onclick={() => abrirModalExcluir(perfil)}
|
||||
disabled={processando || perfil.numeroUsuarios > 0}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Modo: Criar -->
|
||||
{#if modo === "criar"}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl mb-6">Criar Novo Perfil Customizado</h2>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
criarPerfil();
|
||||
}}
|
||||
>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Nome -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="nome">
|
||||
<span class="label-text font-semibold">Nome do Perfil *</span>
|
||||
</label>
|
||||
<input
|
||||
id="nome"
|
||||
type="text"
|
||||
placeholder="Ex: Coordenador de Esportes"
|
||||
class="input input-bordered"
|
||||
bind:value={formNome}
|
||||
required
|
||||
disabled={processando}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Nível -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="nivel">
|
||||
<span class="label-text font-semibold">Nível de Acesso *</span>
|
||||
</label>
|
||||
<input
|
||||
id="nivel"
|
||||
type="number"
|
||||
min="3"
|
||||
class="input input-bordered"
|
||||
bind:value={formNivel}
|
||||
required
|
||||
disabled={processando}
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt">Mínimo: 3 (perfis customizados)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Descrição -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="descricao">
|
||||
<span class="label-text font-semibold">Descrição *</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="descricao"
|
||||
placeholder="Descreva as responsabilidades deste perfil..."
|
||||
class="textarea textarea-bordered h-24"
|
||||
bind:value={formDescricao}
|
||||
required
|
||||
disabled={processando}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Clonar Permissões -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="clonar">
|
||||
<span class="label-text font-semibold">Clonar Permissões de (Opcional)</span>
|
||||
</label>
|
||||
<select
|
||||
id="clonar"
|
||||
class="select select-bordered"
|
||||
bind:value={formClonarDeRoleId}
|
||||
disabled={processando || !rolesQuery?.data}
|
||||
>
|
||||
<option value="">Não clonar (perfil vazio)</option>
|
||||
{#if rolesQuery?.data}
|
||||
{#each rolesQuery.data as role}
|
||||
<option value={role._id}>{role.nome} - {role.descricao}</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
<div class="label">
|
||||
<span class="label-text-alt"
|
||||
>Selecione um perfil existente para copiar suas permissões</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end mt-6">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
onclick={voltar}
|
||||
disabled={processando}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={processando}>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner"></span>
|
||||
{/if}
|
||||
Criar Perfil
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Modo: Editar -->
|
||||
{#if modo === "editar" && perfilSelecionado}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl mb-6">Editar Perfil: {perfilSelecionado.nome}</h2>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
editarPerfil();
|
||||
}}
|
||||
>
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<!-- Nome -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="edit-nome">
|
||||
<span class="label-text font-semibold">Nome do Perfil *</span>
|
||||
</label>
|
||||
<input
|
||||
id="edit-nome"
|
||||
type="text"
|
||||
placeholder="Ex: Coordenador de Esportes"
|
||||
class="input input-bordered"
|
||||
bind:value={formNome}
|
||||
required
|
||||
disabled={processando}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Descrição -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="edit-descricao">
|
||||
<span class="label-text font-semibold">Descrição *</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="edit-descricao"
|
||||
placeholder="Descreva as responsabilidades deste perfil..."
|
||||
class="textarea textarea-bordered h-24"
|
||||
bind:value={formDescricao}
|
||||
required
|
||||
disabled={processando}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Info sobre nível -->
|
||||
<div class="alert alert-info">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-current shrink-0 w-6 h-6"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>O nível de acesso não pode ser alterado após a criação (Nível: {formNivel})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end mt-6">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
onclick={voltar}
|
||||
disabled={processando}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={processando}>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner"></span>
|
||||
{/if}
|
||||
Salvar Alterações
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Modo: Detalhes -->
|
||||
{#if modo === "detalhes" && perfilSelecionado}
|
||||
<div class="space-y-6">
|
||||
<!-- Informações Básicas -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl mb-4">{perfilSelecionado.nome}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-base-content/60">Descrição</p>
|
||||
<p class="text-base-content">{perfilSelecionado.descricao}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-base-content/60">Nível de Acesso</p>
|
||||
<p class="text-base-content">
|
||||
<span class="badge badge-primary">{perfilSelecionado.nivel}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-base-content/60">Criado Por</p>
|
||||
<p class="text-base-content">{perfilSelecionado.criadorNome}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-base-content/60">Criado Em</p>
|
||||
<p class="text-base-content">{formatarData(perfilSelecionado.criadoEm)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-base-content/60">Usuários com este Perfil</p>
|
||||
<p class="text-base-content">
|
||||
<span class="badge badge-ghost"
|
||||
>{perfilSelecionado.numeroUsuarios} usuário{perfilSelecionado.numeroUsuarios !==
|
||||
1
|
||||
? "s"
|
||||
: ""}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Permissões -->
|
||||
{#if !detalhesQuery}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-center items-center py-20">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Permissões de Menu -->
|
||||
{#if detalhesQuery.menuPermissoes && detalhesQuery.menuPermissoes.length > 0}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-xl mb-4">Permissões de Menu</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Menu</th>
|
||||
<th>Acessar</th>
|
||||
<th>Consultar</th>
|
||||
<th>Gravar</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each detalhesQuery.menuPermissoes as perm}
|
||||
<tr>
|
||||
<td class="font-medium">{perm.menuPath}</td>
|
||||
<td>
|
||||
{#if perm.podeAcessar}
|
||||
<span class="badge badge-success badge-sm">Sim</span>
|
||||
{:else}
|
||||
<span class="badge badge-ghost badge-sm">Não</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if perm.podeConsultar}
|
||||
<span class="badge badge-success badge-sm">Sim</span>
|
||||
{:else}
|
||||
<span class="badge badge-ghost badge-sm">Não</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if perm.podeGravar}
|
||||
<span class="badge badge-success badge-sm">Sim</span>
|
||||
{:else}
|
||||
<span class="badge badge-ghost badge-sm">Não</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<a href="/ti/painel-permissoes" class="btn btn-sm btn-primary">
|
||||
Editar Permissões
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="alert alert-warning">
|
||||
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="font-bold">Sem permissões de menu configuradas</h3>
|
||||
<div class="text-sm">
|
||||
Configure as permissões de menu no <a
|
||||
href="/ti/painel-permissoes"
|
||||
class="link">Painel de Permissões</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Usuários com este Perfil -->
|
||||
{#if detalhesQuery.usuarios && detalhesQuery.usuarios.length > 0}
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-xl mb-4">Usuários com este Perfil</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nome</th>
|
||||
<th>Matrícula</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each detalhesQuery.usuarios as usuario}
|
||||
<tr>
|
||||
<td>{usuario.nome}</td>
|
||||
<td>{usuario.matricula}</td>
|
||||
<td>{usuario.email}</td>
|
||||
<td>
|
||||
{#if usuario.ativo && !usuario.bloqueado}
|
||||
<span class="badge badge-success badge-sm">Ativo</span>
|
||||
{:else if usuario.bloqueado}
|
||||
<span class="badge badge-error badge-sm">Bloqueado</span>
|
||||
{:else}
|
||||
<span class="badge badge-ghost badge-sm">Inativo</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Modal de Confirmação de Exclusão -->
|
||||
{#if modalExcluir && perfilParaExcluir}
|
||||
<dialog class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-error"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
Confirmar Exclusão
|
||||
</h3>
|
||||
<p class="py-4">
|
||||
Tem certeza que deseja excluir o perfil <strong>"{perfilParaExcluir.nome}"</strong>?
|
||||
</p>
|
||||
<div class="alert alert-warning">
|
||||
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Esta ação não pode ser desfeita!</span>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-ghost" onclick={fecharModalExcluir} disabled={processando}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button class="btn btn-error" onclick={confirmarExclusao} disabled={processando}>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
Excluir Perfil
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop" onclick={fecharModalExcluir}>
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
{/if}
|
||||
</ProtectedRoute>
|
||||
|
||||
308
apps/web/src/routes/(dashboard)/ti/usuarios/+page.svelte
Normal file
308
apps/web/src/routes/(dashboard)/ti/usuarios/+page.svelte
Normal file
@@ -0,0 +1,308 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import { authStore } from "$lib/stores/auth.svelte";
|
||||
import UserStatusBadge from "$lib/components/ti/UserStatusBadge.svelte";
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
const client = useConvexClient();
|
||||
const usuarios = useQuery(api.usuarios.listar, {});
|
||||
|
||||
let filtroNome = $state("");
|
||||
let filtroStatus = $state<"todos" | "ativo" | "bloqueado" | "inativo">("todos");
|
||||
let usuarioSelecionado = $state<any>(null);
|
||||
let modalAberto = $state(false);
|
||||
let modalAcao = $state<"bloquear" | "desbloquear" | "reset">("bloquear");
|
||||
let motivo = $state("");
|
||||
let processando = $state(false);
|
||||
|
||||
// Usuários filtrados
|
||||
const usuariosFiltrados = $derived.by(() => {
|
||||
if (!usuarios?.data || !Array.isArray(usuarios.data)) return [];
|
||||
|
||||
return usuarios.data.filter(u => {
|
||||
const matchNome = !filtroNome ||
|
||||
u.nome.toLowerCase().includes(filtroNome.toLowerCase()) ||
|
||||
u.matricula.includes(filtroNome) ||
|
||||
u.email?.toLowerCase().includes(filtroNome.toLowerCase());
|
||||
|
||||
const matchStatus = filtroStatus === "todos" ||
|
||||
(filtroStatus === "ativo" && u.ativo && !u.bloqueado) ||
|
||||
(filtroStatus === "bloqueado" && u.bloqueado) ||
|
||||
(filtroStatus === "inativo" && !u.ativo);
|
||||
|
||||
return matchNome && matchStatus;
|
||||
});
|
||||
});
|
||||
|
||||
const stats = $derived.by(() => {
|
||||
if (!usuarios?.data || !Array.isArray(usuarios.data)) return null;
|
||||
return {
|
||||
total: usuarios.data.length,
|
||||
ativos: usuarios.data.filter(u => u.ativo && !u.bloqueado).length,
|
||||
bloqueados: usuarios.data.filter(u => u.bloqueado).length,
|
||||
inativos: usuarios.data.filter(u => !u.ativo).length
|
||||
};
|
||||
});
|
||||
|
||||
function abrirModal(usuario: any, acao: typeof modalAcao) {
|
||||
usuarioSelecionado = usuario;
|
||||
modalAcao = acao;
|
||||
motivo = "";
|
||||
modalAberto = true;
|
||||
}
|
||||
|
||||
function fecharModal() {
|
||||
modalAberto = false;
|
||||
usuarioSelecionado = null;
|
||||
motivo = "";
|
||||
}
|
||||
|
||||
async function executarAcao() {
|
||||
if (!usuarioSelecionado) return;
|
||||
|
||||
if (!authStore.usuario) {
|
||||
alert("Usuário não autenticado");
|
||||
return;
|
||||
}
|
||||
|
||||
processando = true;
|
||||
try {
|
||||
if (modalAcao === "bloquear") {
|
||||
await client.mutation(api.usuarios.bloquearUsuario, {
|
||||
usuarioId: usuarioSelecionado._id as Id<"usuarios">,
|
||||
motivo,
|
||||
bloqueadoPorId: authStore.usuario._id as Id<"usuarios">
|
||||
});
|
||||
} else if (modalAcao === "desbloquear") {
|
||||
await client.mutation(api.usuarios.desbloquearUsuario, {
|
||||
usuarioId: usuarioSelecionado._id as Id<"usuarios">,
|
||||
desbloqueadoPorId: authStore.usuario._id as Id<"usuarios">
|
||||
});
|
||||
} else if (modalAcao === "reset") {
|
||||
await client.mutation(api.usuarios.resetarSenhaUsuario, {
|
||||
usuarioId: usuarioSelecionado._id as Id<"usuarios">,
|
||||
resetadoPorId: authStore.usuario._id as Id<"usuarios">
|
||||
});
|
||||
}
|
||||
|
||||
fecharModal();
|
||||
} catch (error) {
|
||||
console.error("Erro ao executar ação:", error);
|
||||
alert("Erro ao executar ação. Veja o console.");
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto px-4 py-6 max-w-7xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-base-content">Gestão de Usuários</h1>
|
||||
<p class="text-base-content/60 mt-1">Gerenciar usuários do sistema</p>
|
||||
</div>
|
||||
<a href="/ti/usuarios/criar" class="btn btn-primary">
|
||||
<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 Usuário
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
{#if stats}
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="stat bg-base-100 shadow rounded-lg">
|
||||
<div class="stat-title">Total</div>
|
||||
<div class="stat-value text-primary">{stats.total}</div>
|
||||
</div>
|
||||
<div class="stat bg-base-100 shadow rounded-lg">
|
||||
<div class="stat-title">Ativos</div>
|
||||
<div class="stat-value text-success">{stats.ativos}</div>
|
||||
</div>
|
||||
<div class="stat bg-base-100 shadow rounded-lg">
|
||||
<div class="stat-title">Bloqueados</div>
|
||||
<div class="stat-value text-error">{stats.bloqueados}</div>
|
||||
</div>
|
||||
<div class="stat bg-base-100 shadow rounded-lg">
|
||||
<div class="stat-title">Inativos</div>
|
||||
<div class="stat-value text-warning">{stats.inativos}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Filtros -->
|
||||
<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">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Buscar por nome, matrícula ou email</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={filtroNome}
|
||||
placeholder="Digite para buscar..."
|
||||
class="input input-bordered"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Filtrar por status</span>
|
||||
</label>
|
||||
<select bind:value={filtroStatus} class="select select-bordered">
|
||||
<option value="todos">Todos</option>
|
||||
<option value="ativo">Ativos</option>
|
||||
<option value="bloqueado">Bloqueados</option>
|
||||
<option value="inativo">Inativos</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabela -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4">
|
||||
Usuários ({usuariosFiltrados.length})
|
||||
</h2>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Matrícula</th>
|
||||
<th>Nome</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each usuariosFiltrados as usuario}
|
||||
<tr>
|
||||
<td class="font-mono">{usuario.matricula}</td>
|
||||
<td>{usuario.nome}</td>
|
||||
<td>{usuario.email || "-"}</td>
|
||||
<td>
|
||||
<UserStatusBadge ativo={usuario.ativo} bloqueado={usuario.bloqueado} />
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
{#if usuario.bloqueado}
|
||||
<button
|
||||
class="btn btn-sm btn-success"
|
||||
onclick={() => abrirModal(usuario, "desbloquear")}
|
||||
>
|
||||
<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="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Desbloquear
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn-sm btn-error"
|
||||
onclick={() => abrirModal(usuario, "bloquear")}
|
||||
>
|
||||
<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="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Bloquear
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="btn btn-sm btn-warning"
|
||||
onclick={() => abrirModal(usuario, "reset")}
|
||||
>
|
||||
<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="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
Reset Senha
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center py-8 text-base-content/60">
|
||||
Nenhum usuário encontrado
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
{#if modalAberto}
|
||||
<div class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mb-4">
|
||||
{modalAcao === "bloquear" ? "Bloquear Usuário" :
|
||||
modalAcao === "desbloquear" ? "Desbloquear Usuário" :
|
||||
"Resetar Senha"}
|
||||
</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<p class="text-base-content/80">
|
||||
<strong>Usuário:</strong> {usuarioSelecionado?.nome} ({usuarioSelecionado?.matricula})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if modalAcao === "bloquear"}
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Motivo do bloqueio *</span>
|
||||
</label>
|
||||
<textarea
|
||||
bind:value={motivo}
|
||||
class="textarea textarea-bordered"
|
||||
placeholder="Digite o motivo..."
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if modalAcao === "reset"}
|
||||
<div class="alert alert-info mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<span>Uma senha temporária será gerada automaticamente.</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="modal-action">
|
||||
<button
|
||||
class="btn btn-ghost"
|
||||
onclick={fecharModal}
|
||||
disabled={processando}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={executarAcao}
|
||||
disabled={processando || (modalAcao === "bloquear" && !motivo.trim())}
|
||||
>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
Confirmar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop" onclick={fecharModal}></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
559
apps/web/src/routes/(dashboard)/ti/usuarios/criar/+page.svelte
Normal file
559
apps/web/src/routes/(dashboard)/ti/usuarios/criar/+page.svelte
Normal file
@@ -0,0 +1,559 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import { goto } from "$app/navigation";
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
|
||||
|
||||
const client = useConvexClient();
|
||||
const roles = useQuery(api.roles.listar, {});
|
||||
const funcionarios = useQuery(api.funcionarios.getAll, {});
|
||||
|
||||
// Debug - Remover após teste
|
||||
$effect(() => {
|
||||
console.log("=== DEBUG PERFIS ===");
|
||||
console.log("roles:", roles);
|
||||
console.log("roles?.data:", roles?.data);
|
||||
console.log("É array?", Array.isArray(roles?.data));
|
||||
if (roles?.data) {
|
||||
console.log("Quantidade de perfis:", roles.data.length);
|
||||
console.log("Perfis:", roles.data);
|
||||
}
|
||||
});
|
||||
|
||||
// Estados do formulário
|
||||
let matricula = $state("");
|
||||
let nome = $state("");
|
||||
let email = $state("");
|
||||
let roleId = $state("");
|
||||
let funcionarioId = $state("");
|
||||
let senhaInicial = $state("");
|
||||
let confirmarSenha = $state("");
|
||||
let processando = $state(false);
|
||||
let mensagem = $state<{ tipo: "success" | "error"; texto: string } | null>(null);
|
||||
|
||||
function mostrarMensagem(tipo: "success" | "error", texto: string) {
|
||||
mensagem = { tipo, texto };
|
||||
setTimeout(() => {
|
||||
mensagem = null;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
// Validações
|
||||
const matriculaStr = String(matricula).trim();
|
||||
if (!matriculaStr || !nome.trim() || !email.trim() || !roleId || !senhaInicial) {
|
||||
mostrarMensagem("error", "Preencha todos os campos obrigatórios");
|
||||
return;
|
||||
}
|
||||
|
||||
if (senhaInicial !== confirmarSenha) {
|
||||
mostrarMensagem("error", "As senhas não conferem");
|
||||
return;
|
||||
}
|
||||
|
||||
if (senhaInicial.length < 8) {
|
||||
mostrarMensagem("error", "A senha deve ter no mínimo 8 caracteres");
|
||||
return;
|
||||
}
|
||||
|
||||
processando = true;
|
||||
|
||||
try {
|
||||
const resultado = await client.mutation(api.usuarios.criar, {
|
||||
matricula: matriculaStr,
|
||||
nome: nome.trim(),
|
||||
email: email.trim(),
|
||||
roleId: roleId as Id<"roles">,
|
||||
funcionarioId: funcionarioId ? (funcionarioId as Id<"funcionarios">) : undefined,
|
||||
senhaInicial: senhaInicial,
|
||||
});
|
||||
|
||||
if (resultado.sucesso) {
|
||||
if (senhaGerada) {
|
||||
mostrarMensagem(
|
||||
"success",
|
||||
`Usuário criado! SENHA TEMPORÁRIA: ${senhaGerada} - Anote esta senha, ela não será exibida novamente!`
|
||||
);
|
||||
setTimeout(() => {
|
||||
goto("/ti/usuarios");
|
||||
}, 5000);
|
||||
} else {
|
||||
mostrarMensagem("success", "Usuário criado com sucesso!");
|
||||
setTimeout(() => {
|
||||
goto("/ti/usuarios");
|
||||
}, 2000);
|
||||
}
|
||||
} else {
|
||||
mostrarMensagem("error", resultado.erro);
|
||||
}
|
||||
} catch (error: any) {
|
||||
mostrarMensagem("error", error.message || "Erro ao criar usuário");
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
let senhaGerada = $state("");
|
||||
let mostrarSenha = $state(false);
|
||||
|
||||
// Auto-completar ao selecionar funcionário
|
||||
$effect(() => {
|
||||
if (funcionarioId && funcionarios?.data) {
|
||||
const funcSelecionado = funcionarios.data.find((f: any) => f._id === funcionarioId);
|
||||
if (funcSelecionado) {
|
||||
email = funcSelecionado.email || email;
|
||||
nome = funcSelecionado.nome || nome;
|
||||
matricula = funcSelecionado.matricula || matricula;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function gerarSenhaAleatoria() {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$!";
|
||||
let senha = "";
|
||||
for (let i = 0; i < 12; i++) {
|
||||
senha += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
senhaInicial = senha;
|
||||
confirmarSenha = senha;
|
||||
senhaGerada = senha;
|
||||
mostrarSenha = true;
|
||||
}
|
||||
|
||||
function copiarSenha() {
|
||||
if (senhaGerada) {
|
||||
navigator.clipboard.writeText(senhaGerada);
|
||||
mostrarMensagem("success", "Senha copiada para área de transferência!");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ProtectedRoute allowedRoles={["ti_master", "admin"]} maxLevel={1}>
|
||||
<div class="container mx-auto px-4 py-6 max-w-4xl">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-primary/10 rounded-xl">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-10 w-10 text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-base-content">Criar Novo Usuário</h1>
|
||||
<p class="text-base-content/60 mt-1">Cadastre um novo usuário no sistema</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/ti/usuarios" class="btn btn-outline btn-primary gap-2">
|
||||
<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 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
Voltar para Usuários
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumbs -->
|
||||
<div class="text-sm breadcrumbs mb-6">
|
||||
<ul>
|
||||
<li><a href="/ti/painel-administrativo">Dashboard TI</a></li>
|
||||
<li><a href="/ti/usuarios">Usuários</a></li>
|
||||
<li>Criar Usuário</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Mensagens -->
|
||||
{#if mensagem}
|
||||
<div
|
||||
class="alert mb-6"
|
||||
class:alert-success={mensagem.tipo === "success"}
|
||||
class:alert-error={mensagem.tipo === "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"
|
||||
>
|
||||
{#if mensagem.tipo === "success"}
|
||||
<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"
|
||||
/>
|
||||
{:else}
|
||||
<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"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
<span>{mensagem.texto}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Formulário -->
|
||||
<div class="card bg-base-100 shadow-2xl border border-base-300">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="p-2 bg-primary/10 rounded-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="card-title text-2xl">Informações do Usuário</h2>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleSubmit}>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Funcionário (primeiro) -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="funcionario">
|
||||
<span class="label-text font-semibold">Vincular Funcionário (Opcional)</span>
|
||||
</label>
|
||||
<select
|
||||
id="funcionario"
|
||||
class="select select-bordered"
|
||||
bind:value={funcionarioId}
|
||||
disabled={processando || !funcionarios?.data}
|
||||
>
|
||||
<option value="">Selecione um funcionário para auto-completar dados</option>
|
||||
{#if funcionarios?.data}
|
||||
{#each funcionarios.data as func}
|
||||
<option value={func._id}>{func.nome} - Mat: {func.matricula}</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
<div class="label">
|
||||
<span class="label-text-alt">Ao selecionar, os campos serão preenchidos automaticamente</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Matrícula -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="matricula">
|
||||
<span class="label-text font-semibold">Matrícula *</span>
|
||||
</label>
|
||||
<input
|
||||
id="matricula"
|
||||
type="number"
|
||||
placeholder="Ex: 12345"
|
||||
class="input input-bordered"
|
||||
bind:value={matricula}
|
||||
required
|
||||
disabled={processando}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Nome -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="nome">
|
||||
<span class="label-text font-semibold">Nome Completo *</span>
|
||||
</label>
|
||||
<input
|
||||
id="nome"
|
||||
type="text"
|
||||
placeholder="Ex: João da Silva"
|
||||
class="input input-bordered"
|
||||
bind:value={nome}
|
||||
required
|
||||
disabled={processando}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="form-control md:col-span-2">
|
||||
<label class="label" for="email">
|
||||
<span class="label-text font-semibold">E-mail *</span>
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="usuario@sgse.pe.gov.br"
|
||||
class="input input-bordered"
|
||||
bind:value={email}
|
||||
required
|
||||
disabled={processando}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Perfil/Role -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="role">
|
||||
<span class="label-text font-semibold">Perfil de Acesso *</span>
|
||||
</label>
|
||||
<select
|
||||
id="role"
|
||||
class="select select-bordered"
|
||||
bind:value={roleId}
|
||||
required
|
||||
disabled={processando || !roles?.data}
|
||||
>
|
||||
<option value="">Selecione um perfil</option>
|
||||
{#if roles?.data && Array.isArray(roles.data)}
|
||||
{#each roles.data as role}
|
||||
<option value={role._id}>
|
||||
{role.descricao} ({role.nome})
|
||||
</option>
|
||||
{/each}
|
||||
{:else}
|
||||
<option disabled>Carregando perfis...</option>
|
||||
{/if}
|
||||
</select>
|
||||
{#if !roles?.data || !Array.isArray(roles.data)}
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-warning">Carregando perfis disponíveis...</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="divider md:col-span-2 mt-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
Senha Inicial
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Senha -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="senha">
|
||||
<span class="label-text font-semibold">Senha Inicial *</span>
|
||||
</label>
|
||||
<input
|
||||
id="senha"
|
||||
type="password"
|
||||
placeholder="Mínimo 8 caracteres"
|
||||
class="input input-bordered"
|
||||
bind:value={senhaInicial}
|
||||
required
|
||||
minlength="8"
|
||||
disabled={processando}
|
||||
/>
|
||||
<div class="label">
|
||||
<span class="label-text-alt">Mínimo 8 caracteres</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmar Senha -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="confirmar-senha">
|
||||
<span class="label-text font-semibold">Confirmar Senha *</span>
|
||||
</label>
|
||||
<input
|
||||
id="confirmar-senha"
|
||||
type="password"
|
||||
placeholder="Digite novamente"
|
||||
class="input input-bordered"
|
||||
bind:value={confirmarSenha}
|
||||
required
|
||||
minlength="8"
|
||||
disabled={processando}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Botão Gerar Senha e Visualização -->
|
||||
<div class="md:col-span-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline btn-info"
|
||||
onclick={gerarSenhaAleatoria}
|
||||
disabled={processando}
|
||||
>
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
Gerar Senha Forte Aleatória
|
||||
</button>
|
||||
|
||||
{#if mostrarSenha && senhaGerada}
|
||||
<div class="alert alert-warning mt-4">
|
||||
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-bold">Senha Gerada:</h3>
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<code class="bg-base-300 px-3 py-2 rounded text-lg font-mono select-all">
|
||||
{senhaGerada}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
onclick={copiarSenha}
|
||||
title="Copiar senha"
|
||||
>
|
||||
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Copiar
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm mt-2">
|
||||
⚠️ <strong>IMPORTANTE:</strong> Anote esta senha! Você precisará repassá-la
|
||||
manualmente ao usuário até que o SMTP seja configurado.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-current shrink-0 w-6 h-6"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="font-bold">Informações Importantes</h3>
|
||||
<ul class="text-sm list-disc list-inside mt-2 space-y-1">
|
||||
<li>O usuário deverá alterar a senha no primeiro acesso</li>
|
||||
<li>As credenciais devem ser repassadas manualmente (por enquanto)</li>
|
||||
<li>
|
||||
Configure o SMTP em <a href="/ti/configuracoes-email" class="link"
|
||||
>Configurações de Email</a
|
||||
> para envio automático
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end mt-8 pt-6 border-t border-base-300">
|
||||
<a href="/ti/usuarios" class="btn btn-ghost gap-2" class:btn-disabled={processando}>
|
||||
<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="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
Cancelar
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary gap-2" disabled={processando}>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Criando Usuário...
|
||||
{:else}
|
||||
<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="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Criar Usuário
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
|
||||
Reference in New Issue
Block a user