feat: enhance employee and symbol management with new features, improved UI components, and backend schema updates
This commit is contained in:
@@ -1,2 +1,20 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@plugin "daisyui";
|
@plugin "daisyui";
|
||||||
|
|
||||||
|
/* Estilo padrão dos botões - mesmo estilo do sidebar */
|
||||||
|
.btn-standard {
|
||||||
|
@apply font-medium flex items-center justify-center gap-2 text-center p-3 rounded-xl border border-base-300 bg-base-100 hover:bg-primary/60 active:bg-primary text-base-content hover:text-white active:text-white transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sobrescrever estilos DaisyUI para seguir o padrão */
|
||||||
|
.btn-primary {
|
||||||
|
@apply font-medium flex items-center justify-center gap-2 text-center px-4 py-2 rounded-xl border border-base-300 bg-base-100 hover:bg-primary/60 active:bg-primary text-base-content hover:text-white active:text-white transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
@apply font-medium flex items-center justify-center gap-2 text-center px-4 py-2 rounded-xl border border-base-300 bg-base-100 hover:bg-base-200 active:bg-base-300 text-base-content transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-error {
|
||||||
|
@apply font-medium flex items-center justify-center gap-2 text-center px-4 py-2 rounded-xl border border-error bg-base-100 hover:bg-error/60 active:bg-error text-error hover:text-white active:text-white transition-colors;
|
||||||
|
}
|
||||||
9
apps/web/src/hooks.server.ts
Normal file
9
apps/web/src/hooks.server.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Handle } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
// Middleware desabilitado - proteção de rotas feita no lado do cliente
|
||||||
|
// para compatibilidade com localStorage do authStore
|
||||||
|
|
||||||
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
|
return resolve(event);
|
||||||
|
};
|
||||||
|
|
||||||
154
apps/web/src/lib/components/MenuProtection.svelte
Normal file
154
apps/web/src/lib/components/MenuProtection.svelte
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useQuery } from "convex-svelte";
|
||||||
|
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||||
|
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||||
|
import { authStore } from "$lib/stores/auth.svelte";
|
||||||
|
import { loginModalStore } from "$lib/stores/loginModal.svelte";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
interface MenuProtectionProps {
|
||||||
|
menuPath: string;
|
||||||
|
requireGravar?: boolean;
|
||||||
|
children?: any;
|
||||||
|
redirectTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
menuPath,
|
||||||
|
requireGravar = false,
|
||||||
|
children,
|
||||||
|
redirectTo = "/",
|
||||||
|
}: MenuProtectionProps = $props();
|
||||||
|
|
||||||
|
let verificando = $state(true);
|
||||||
|
let temPermissao = $state(false);
|
||||||
|
let motivoNegacao = $state("");
|
||||||
|
|
||||||
|
// Query para verificar permissões (só executa se o usuário estiver autenticado)
|
||||||
|
const permissaoQuery = $derived(
|
||||||
|
authStore.usuario
|
||||||
|
? useQuery(api.menuPermissoes.verificarAcesso, {
|
||||||
|
usuarioId: authStore.usuario._id as Id<"usuarios">,
|
||||||
|
menuPath: menuPath,
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
verificarPermissoes();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Re-verificar quando o status de autenticação mudar
|
||||||
|
if (authStore.autenticado !== undefined) {
|
||||||
|
verificarPermissoes();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Re-verificar quando a query carregar
|
||||||
|
if (permissaoQuery?.data) {
|
||||||
|
verificarPermissoes();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function verificarPermissoes() {
|
||||||
|
// Dashboard e Solicitar Acesso são públicos
|
||||||
|
if (menuPath === "/" || menuPath === "/solicitar-acesso") {
|
||||||
|
verificando = false;
|
||||||
|
temPermissao = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se não está autenticado
|
||||||
|
if (!authStore.autenticado) {
|
||||||
|
verificando = false;
|
||||||
|
temPermissao = false;
|
||||||
|
motivoNegacao = "auth_required";
|
||||||
|
|
||||||
|
// Abrir modal de login e salvar rota de redirecionamento
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
loginModalStore.open(currentPath);
|
||||||
|
|
||||||
|
// NÃO redirecionar, apenas mostrar o modal
|
||||||
|
// O usuário verá a mensagem "Verificando permissões..." enquanto o modal está aberto
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se está autenticado, verificar permissões
|
||||||
|
if (permissaoQuery?.data) {
|
||||||
|
const permissao = permissaoQuery.data;
|
||||||
|
|
||||||
|
// Se não pode acessar
|
||||||
|
if (!permissao.podeAcessar) {
|
||||||
|
verificando = false;
|
||||||
|
temPermissao = false;
|
||||||
|
motivoNegacao = "access_denied";
|
||||||
|
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se requer gravação mas não tem permissão
|
||||||
|
if (requireGravar && !permissao.podeGravar) {
|
||||||
|
verificando = false;
|
||||||
|
temPermissao = false;
|
||||||
|
motivoNegacao = "write_denied";
|
||||||
|
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
window.location.href = `${redirectTo}?error=write_denied&route=${encodeURIComponent(currentPath)}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tem permissão!
|
||||||
|
verificando = false;
|
||||||
|
temPermissao = true;
|
||||||
|
} else if (permissaoQuery?.error) {
|
||||||
|
verificando = false;
|
||||||
|
temPermissao = false;
|
||||||
|
motivoNegacao = "error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if verificando}
|
||||||
|
<div class="flex items-center justify-center min-h-screen">
|
||||||
|
<div class="text-center">
|
||||||
|
{#if motivoNegacao === "auth_required"}
|
||||||
|
<div class="p-4 bg-warning/10 rounded-full inline-block mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-warning" 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>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold text-base-content mb-2">Acesso Restrito</h2>
|
||||||
|
<p class="text-base-content/70 mb-4">
|
||||||
|
Esta área requer autenticação.<br />
|
||||||
|
Por favor, faça login para continuar.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
<p class="mt-4 text-base-content/70">Verificando permissões...</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if temPermissao}
|
||||||
|
{@render children?.()}
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center justify-center min-h-screen">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="p-4 bg-error/10 rounded-full inline-block mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 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>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold text-base-content mb-2">Acesso Negado</h2>
|
||||||
|
<p class="text-base-content/70 mb-4">Você não tem permissão para acessar esta página.</p>
|
||||||
|
<button class="btn btn-primary" onclick={() => goto(redirectTo)}>
|
||||||
|
Voltar ao Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
74
apps/web/src/lib/components/ProtectedRoute.svelte
Normal file
74
apps/web/src/lib/components/ProtectedRoute.svelte
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { authStore } from "$lib/stores/auth.svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
children,
|
||||||
|
requireAuth = true,
|
||||||
|
allowedRoles = [],
|
||||||
|
maxLevel = 3,
|
||||||
|
redirectTo = "/"
|
||||||
|
}: {
|
||||||
|
children: Snippet;
|
||||||
|
requireAuth?: boolean;
|
||||||
|
allowedRoles?: string[];
|
||||||
|
maxLevel?: number;
|
||||||
|
redirectTo?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let isChecking = $state(true);
|
||||||
|
let hasAccess = $state(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
checkAccess();
|
||||||
|
});
|
||||||
|
|
||||||
|
function checkAccess() {
|
||||||
|
isChecking = true;
|
||||||
|
|
||||||
|
// Aguardar um pouco para o authStore carregar do localStorage
|
||||||
|
setTimeout(() => {
|
||||||
|
// Verificar autenticação
|
||||||
|
if (requireAuth && !authStore.autenticado) {
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
window.location.href = `${redirectTo}?error=auth_required&redirect=${encodeURIComponent(currentPath)}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar roles
|
||||||
|
if (allowedRoles.length > 0 && authStore.usuario) {
|
||||||
|
const hasRole = allowedRoles.includes(authStore.usuario.role.nome);
|
||||||
|
if (!hasRole) {
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar nível
|
||||||
|
if (authStore.usuario && authStore.usuario.role.nivel > maxLevel) {
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAccess = true;
|
||||||
|
isChecking = false;
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isChecking}
|
||||||
|
<div class="flex justify-center items-center min-h-screen">
|
||||||
|
<div class="text-center">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
<p class="mt-4 text-base-content/70">Verificando permissões...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if hasAccess}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
import logo from "$lib/assets/logo_governo_PE.png";
|
import logo from "$lib/assets/logo_governo_PE.png";
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
import { authStore } from "$lib/stores/auth.svelte";
|
||||||
|
import { loginModalStore } from "$lib/stores/loginModal.svelte";
|
||||||
|
import { useConvexClient } from "convex-svelte";
|
||||||
|
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||||
|
|
||||||
let { children }: { children: Snippet } = $props();
|
let { children }: { children: Snippet } = $props();
|
||||||
|
|
||||||
|
const convex = useConvexClient();
|
||||||
|
|
||||||
const setores = [
|
const setores = [
|
||||||
{ nome: "Recursos Humanos", link: "/recursos-humanos" },
|
{ nome: "Recursos Humanos", link: "/recursos-humanos" },
|
||||||
{ nome: "Financeiro", link: "/financeiro" },
|
{ nome: "Financeiro", link: "/financeiro" },
|
||||||
@@ -13,6 +20,7 @@
|
|||||||
{ nome: "Compras", link: "/compras" },
|
{ nome: "Compras", link: "/compras" },
|
||||||
{ nome: "Jurídico", link: "/juridico" },
|
{ nome: "Jurídico", link: "/juridico" },
|
||||||
{ nome: "Comunicação", link: "/comunicacao" },
|
{ nome: "Comunicação", link: "/comunicacao" },
|
||||||
|
{ nome: "Programas Esportivos", link: "/programas-esportivos" },
|
||||||
{ nome: "Secretaria Executiva", link: "/secretaria-executiva" },
|
{ nome: "Secretaria Executiva", link: "/secretaria-executiva" },
|
||||||
{
|
{
|
||||||
nome: "Secretaria de Gestão de Pessoas",
|
nome: "Secretaria de Gestão de Pessoas",
|
||||||
@@ -20,12 +28,97 @@
|
|||||||
},
|
},
|
||||||
{ nome: "Tecnologia da Informação", link: "/ti" },
|
{ nome: "Tecnologia da Informação", link: "/ti" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let showAboutModal = $state(false);
|
||||||
|
let matricula = $state("");
|
||||||
|
let senha = $state("");
|
||||||
|
let erroLogin = $state("");
|
||||||
|
let carregandoLogin = $state(false);
|
||||||
|
|
||||||
|
// Sincronizar com o store global
|
||||||
|
$effect(() => {
|
||||||
|
if (loginModalStore.showModal && !matricula && !senha) {
|
||||||
|
matricula = "";
|
||||||
|
senha = "";
|
||||||
|
erroLogin = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function openLoginModal() {
|
||||||
|
loginModalStore.open();
|
||||||
|
matricula = "";
|
||||||
|
senha = "";
|
||||||
|
erroLogin = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLoginModal() {
|
||||||
|
loginModalStore.close();
|
||||||
|
matricula = "";
|
||||||
|
senha = "";
|
||||||
|
erroLogin = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAboutModal() {
|
||||||
|
showAboutModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAboutModal() {
|
||||||
|
showAboutModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
erroLogin = "";
|
||||||
|
carregandoLogin = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resultado = await convex.mutation(api.autenticacao.login, {
|
||||||
|
matricula: matricula.trim(),
|
||||||
|
senha: senha,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resultado.sucesso) {
|
||||||
|
authStore.login(resultado.usuario, resultado.token);
|
||||||
|
closeLoginModal();
|
||||||
|
|
||||||
|
// Redirecionar baseado no role
|
||||||
|
if (resultado.usuario.role.nome === "ti" || resultado.usuario.role.nivel === 0) {
|
||||||
|
goto("/ti/painel-administrativo");
|
||||||
|
} else if (resultado.usuario.role.nome === "rh") {
|
||||||
|
goto("/recursos-humanos");
|
||||||
|
} else {
|
||||||
|
goto("/");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
erroLogin = resultado.erro || "Erro ao fazer login";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao fazer login:", error);
|
||||||
|
erroLogin = "Erro ao conectar com o servidor. Tente novamente.";
|
||||||
|
} finally {
|
||||||
|
carregandoLogin = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
if (authStore.token) {
|
||||||
|
try {
|
||||||
|
await convex.mutation(api.autenticacao.logout, {
|
||||||
|
token: authStore.token,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao fazer logout:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
authStore.logout();
|
||||||
|
goto("/");
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Header Fixo acima de tudo -->
|
<!-- Header Fixo acima de tudo -->
|
||||||
<div class="navbar bg-primary/20 shadow-md px-6 lg:px-8 fixed top-0 left-0 right-0 z-50 min-h-24">
|
<div class="navbar bg-gradient-to-r from-primary/30 via-primary/20 to-primary/30 backdrop-blur-sm shadow-lg border-b border-primary/10 px-6 lg:px-8 fixed top-0 left-0 right-0 z-50 min-h-24">
|
||||||
<div class="flex-none lg:hidden">
|
<div class="flex-none lg:hidden">
|
||||||
<label for="my-drawer-3" class="btn btn-square btn-ghost">
|
<label for="my-drawer-3" class="btn btn-square btn-ghost hover:bg-primary/20">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -41,15 +134,81 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 flex items-center gap-4">
|
<div class="flex-1 flex items-center gap-4 lg:gap-6">
|
||||||
<img src={logo} alt="Logo do Governo de PE" class="h-14 lg:h-16 w-auto hidden lg:block" />
|
<div class="avatar">
|
||||||
|
<div class="w-16 lg:w-20 rounded-lg shadow-md bg-white p-2">
|
||||||
|
<img src={logo} alt="Logo do Governo de PE" class="w-full h-full object-contain" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<h1 class="text-xl lg:text-3xl font-bold text-primary">SGSE</h1>
|
<h1 class="text-xl lg:text-3xl font-bold text-primary tracking-tight">SGSE</h1>
|
||||||
<p class="text-base lg:text-2xl text-base-content/70 hidden sm:block font-semibold">
|
<p class="text-xs lg:text-base text-base-content/80 hidden sm:block font-medium leading-tight">
|
||||||
Sistema de Gerenciamento da Secretaria de Esportes
|
Sistema de Gerenciamento da<br class="lg:hidden" /> Secretaria de Esportes
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex-none flex items-center gap-4">
|
||||||
|
{#if authStore.autenticado}
|
||||||
|
<div class="hidden lg:flex flex-col items-end">
|
||||||
|
<span class="text-sm font-semibold text-primary">{authStore.usuario?.nome}</span>
|
||||||
|
<span class="text-xs text-base-content/60">{authStore.usuario?.role.nome}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown dropdown-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabindex="0"
|
||||||
|
class="btn btn-primary btn-circle btn-lg shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105"
|
||||||
|
aria-label="Menu do usuário"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
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>
|
||||||
|
</button>
|
||||||
|
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow-xl bg-base-100 rounded-box w-52 mt-4 border border-primary/20">
|
||||||
|
<li class="menu-title">
|
||||||
|
<span class="text-primary font-bold">{authStore.usuario?.nome}</span>
|
||||||
|
</li>
|
||||||
|
<li><a href="/perfil">Meu Perfil</a></li>
|
||||||
|
<li><a href="/alterar-senha">Alterar Senha</a></li>
|
||||||
|
<div class="divider my-0"></div>
|
||||||
|
<li><button type="button" onclick={handleLogout} class="text-error">Sair</button></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-circle btn-lg shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105"
|
||||||
|
onclick={() => openLoginModal()}
|
||||||
|
aria-label="Login"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
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>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="drawer lg:drawer-open" style="margin-top: 96px;">
|
<div class="drawer lg:drawer-open" style="margin-top: 96px;">
|
||||||
@@ -61,35 +220,41 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="footer footer-center bg-primary/20 text-base-content p-4 border-t border-base-300 flex-shrink-0">
|
<footer class="footer footer-center bg-gradient-to-r from-primary/30 via-primary/20 to-primary/30 backdrop-blur-sm text-base-content p-6 border-t-2 border-primary/20 flex-shrink-0 shadow-inner">
|
||||||
<div class="grid grid-flow-col gap-4">
|
<div class="grid grid-flow-col gap-6 text-sm font-medium">
|
||||||
<a href="/" class="link link-hover text-sm">Sobre</a>
|
<button type="button" class="link link-hover hover:text-primary transition-colors" onclick={() => openAboutModal()}>Sobre</button>
|
||||||
<a href="/" class="link link-hover text-sm">Contato</a>
|
<span class="text-base-content/30">•</span>
|
||||||
<a href="/" class="link link-hover text-sm">Suporte</a>
|
<a href="/" class="link link-hover hover:text-primary transition-colors">Contato</a>
|
||||||
<a href="/" class="link link-hover text-sm">Política de Privacidade</a>
|
<span class="text-base-content/30">•</span>
|
||||||
|
<a href="/" class="link link-hover hover:text-primary transition-colors">Suporte</a>
|
||||||
|
<span class="text-base-content/30">•</span>
|
||||||
|
<a href="/" class="link link-hover hover:text-primary transition-colors">Privacidade</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-center gap-2">
|
<div class="flex items-center gap-3 mt-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="avatar">
|
||||||
<img src={logo} alt="Logo" class="h-8 w-auto" />
|
<div class="w-10 rounded-lg bg-white p-1.5 shadow-md">
|
||||||
<span class="font-semibold">Governo do Estado de Pernambuco</span>
|
<img src={logo} alt="Logo" class="w-full h-full object-contain" />
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-base-content/70">
|
|
||||||
Secretaria de Esportes © {new Date().getFullYear()} - Todos os direitos reservados
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-left">
|
||||||
|
<p class="text-xs font-bold text-primary">Governo do Estado de Pernambuco</p>
|
||||||
|
<p class="text-xs text-base-content/70">Secretaria de Esportes</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-base-content/60 mt-2">© {new Date().getFullYear()} - Todos os direitos reservados</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
<div class="drawer-side z-40 fixed" style="margin-top: 96px;">
|
<div class="drawer-side z-40 fixed" style="margin-top: 96px;">
|
||||||
<label for="my-drawer-3" aria-label="close sidebar" class="drawer-overlay"
|
<label for="my-drawer-3" aria-label="close sidebar" class="drawer-overlay"
|
||||||
></label>
|
></label>
|
||||||
<div class="menu bg-primary/20 w-72 p-4 flex flex-col gap-2 h-[calc(100vh-96px)] overflow-y-auto">
|
<div class="menu bg-gradient-to-b from-primary/25 to-primary/15 backdrop-blur-sm w-72 p-4 flex flex-col gap-2 h-[calc(100vh-96px)] overflow-y-auto border-r-2 border-primary/20 shadow-xl">
|
||||||
<!-- Sidebar menu items -->
|
<!-- Sidebar menu items -->
|
||||||
<ul class="flex flex-col gap-2">
|
<ul class="flex flex-col gap-2">
|
||||||
<li class="rounded-xl">
|
<li class="rounded-xl">
|
||||||
<a href="/" class="font-medium flex items-center justify-center gap-2 text-center p-3 rounded-xl border border-base-300 bg-base-100 hover:bg-primary/60 active:bg-primary text-base-content hover:text-white active:text-white transition-colors">
|
<a href="/" class="group font-semibold flex items-center justify-center gap-2 text-center p-3.5 rounded-xl border-2 border-primary/30 bg-gradient-to-br from-base-100 to-base-200 hover:from-primary hover:to-primary/80 active:from-primary/90 active:to-primary text-base-content hover:text-white active:text-white transition-all duration-300 shadow-md hover:shadow-lg hover:scale-105">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-5 w-5"
|
class="h-5 w-5 group-hover:scale-110 transition-transform"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -110,14 +275,14 @@
|
|||||||
href={s.link}
|
href={s.link}
|
||||||
class:active={page.url.pathname.startsWith(s.link)}
|
class:active={page.url.pathname.startsWith(s.link)}
|
||||||
aria-current={page.url.pathname.startsWith(s.link) ? "page" : undefined}
|
aria-current={page.url.pathname.startsWith(s.link) ? "page" : undefined}
|
||||||
class="font-medium flex items-center justify-center gap-2 text-center p-3 rounded-xl border border-base-300 bg-base-100 hover:bg-primary/60 active:bg-primary text-base-content hover:text-white active:text-white transition-colors"
|
class="group font-semibold flex items-center justify-center gap-2 text-center p-3.5 rounded-xl border-2 border-primary/30 bg-gradient-to-br from-base-100 to-base-200 hover:from-primary hover:to-primary/80 active:from-primary/90 active:to-primary text-base-content hover:text-white active:text-white transition-all duration-300 shadow-md hover:shadow-lg hover:scale-105"
|
||||||
>
|
>
|
||||||
<span>{s.nome}</span>
|
<span>{s.nome}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
<li class="rounded-xl mt-auto">
|
<li class="rounded-xl mt-auto">
|
||||||
<a href="/" class="font-medium flex items-center justify-center gap-2 text-center p-3 rounded-xl border border-base-300 bg-base-100 hover:bg-primary/60 active:bg-primary text-base-content hover:text-white active:text-white transition-colors">
|
<a href="/solicitar-acesso" class="group font-semibold flex items-center justify-center gap-2 text-center p-3.5 rounded-xl border-2 border-success/30 bg-gradient-to-br from-success/10 to-success/20 hover:from-success hover:to-success/80 active:from-success/90 active:to-success text-base-content hover:text-white active:text-white transition-all duration-300 shadow-md hover:shadow-lg hover:scale-105">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-5 w-5"
|
class="h-5 w-5"
|
||||||
@@ -139,3 +304,197 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal de Login -->
|
||||||
|
{#if loginModalStore.showModal}
|
||||||
|
<dialog class="modal modal-open">
|
||||||
|
<div class="modal-box relative overflow-hidden bg-base-100 max-w-md">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
||||||
|
onclick={closeLoginModal}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<div class="avatar mb-4">
|
||||||
|
<div class="w-20 rounded-lg bg-primary/10 p-3">
|
||||||
|
<img src={logo} alt="Logo" class="w-full h-full object-contain" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-bold text-3xl text-primary">Login</h3>
|
||||||
|
<p class="text-sm text-base-content/60 mt-2">Acesse o sistema com suas credenciais</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if erroLogin}
|
||||||
|
<div class="alert alert-error mb-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="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>{erroLogin}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form class="space-y-4" onsubmit={handleLogin}>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="login-matricula">
|
||||||
|
<span class="label-text font-semibold">Matrícula</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="login-matricula"
|
||||||
|
type="text"
|
||||||
|
placeholder="Digite sua matrícula"
|
||||||
|
class="input input-bordered input-primary w-full"
|
||||||
|
bind:value={matricula}
|
||||||
|
required
|
||||||
|
disabled={carregandoLogin}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="login-password">
|
||||||
|
<span class="label-text font-semibold">Senha</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="login-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Digite sua senha"
|
||||||
|
class="input input-bordered input-primary w-full"
|
||||||
|
bind:value={senha}
|
||||||
|
required
|
||||||
|
disabled={carregandoLogin}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-control mt-6">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary w-full"
|
||||||
|
disabled={carregandoLogin}
|
||||||
|
>
|
||||||
|
{#if carregandoLogin}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
Entrando...
|
||||||
|
{: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="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>
|
||||||
|
Entrar
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="text-center mt-4 space-y-2">
|
||||||
|
<a href="/solicitar-acesso" class="link link-primary text-sm block" onclick={closeLoginModal}>
|
||||||
|
Não tem acesso? Solicite aqui
|
||||||
|
</a>
|
||||||
|
<a href="/esqueci-senha" class="link link-secondary text-sm block" onclick={closeLoginModal}>
|
||||||
|
Esqueceu sua senha?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="divider text-xs text-base-content/40">Credenciais de teste</div>
|
||||||
|
<div class="bg-base-200 p-3 rounded-lg text-xs">
|
||||||
|
<p class="font-semibold mb-1">Admin:</p>
|
||||||
|
<p>Matrícula: <code class="bg-base-300 px-2 py-1 rounded">0000</code></p>
|
||||||
|
<p>Senha: <code class="bg-base-300 px-2 py-1 rounded">Admin@123</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop" onclick={closeLoginModal}>
|
||||||
|
<button type="button">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Modal Sobre -->
|
||||||
|
{#if showAboutModal}
|
||||||
|
<dialog class="modal modal-open">
|
||||||
|
<div class="modal-box max-w-2xl relative overflow-hidden bg-gradient-to-br from-base-100 to-base-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
||||||
|
onclick={closeAboutModal}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="text-center space-y-6 py-4">
|
||||||
|
<!-- Logo e Título -->
|
||||||
|
<div class="flex flex-col items-center gap-4">
|
||||||
|
<div class="avatar">
|
||||||
|
<div class="w-24 rounded-xl bg-white p-3 shadow-lg">
|
||||||
|
<img src={logo} alt="Logo SGSE" class="w-full h-full object-contain" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-3xl font-bold text-primary mb-2">SGSE</h3>
|
||||||
|
<p class="text-lg font-semibold text-base-content/80">
|
||||||
|
Sistema de Gerenciamento da<br />Secretaria de Esportes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<!-- Informações de Versão -->
|
||||||
|
<div class="bg-primary/10 rounded-xl p-6 space-y-3">
|
||||||
|
<div class="flex items-center justify-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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm font-medium text-base-content/70">Versão</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-2xl font-bold text-primary">1.0 26_2025</p>
|
||||||
|
<div class="badge badge-warning badge-lg gap-2">
|
||||||
|
<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 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
|
</svg>
|
||||||
|
Em Desenvolvimento
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desenvolvido por -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm font-medium text-base-content/60">Desenvolvido por</p>
|
||||||
|
<p class="text-lg font-bold text-primary">
|
||||||
|
Secretaria de Esportes de Pernambuco
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<!-- Informações Adicionais -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div class="bg-base-200 rounded-lg p-3">
|
||||||
|
<p class="font-semibold text-primary">Governo</p>
|
||||||
|
<p class="text-xs text-base-content/70">Estado de Pernambuco</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-base-200 rounded-lg p-3">
|
||||||
|
<p class="font-semibold text-primary">Ano</p>
|
||||||
|
<p class="text-xs text-base-content/70">2025</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botão OK -->
|
||||||
|
<div class="pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-lg w-full max-w-xs mx-auto shadow-lg hover:shadow-xl transition-all duration-300"
|
||||||
|
onclick={closeAboutModal}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop" onclick={closeAboutModal} role="button" tabindex="0" onkeydown={(e) => e.key === 'Escape' && closeAboutModal()}>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
|||||||
112
apps/web/src/lib/stores/auth.svelte.ts
Normal file
112
apps/web/src/lib/stores/auth.svelte.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { browser } from "$app/environment";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
interface Usuario {
|
||||||
|
_id: string;
|
||||||
|
matricula: string;
|
||||||
|
nome: string;
|
||||||
|
email: string;
|
||||||
|
role: {
|
||||||
|
_id: string;
|
||||||
|
nome: string;
|
||||||
|
nivel: number;
|
||||||
|
setor?: string;
|
||||||
|
};
|
||||||
|
primeiroAcesso: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
usuario: Usuario | null;
|
||||||
|
token: string | null;
|
||||||
|
carregando: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthStore {
|
||||||
|
private state = $state<AuthState>({
|
||||||
|
usuario: null,
|
||||||
|
token: null,
|
||||||
|
carregando: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (browser) {
|
||||||
|
this.carregarDoLocalStorage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get usuario() {
|
||||||
|
return this.state.usuario;
|
||||||
|
}
|
||||||
|
|
||||||
|
get token() {
|
||||||
|
return this.state.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
get carregando() {
|
||||||
|
return this.state.carregando;
|
||||||
|
}
|
||||||
|
|
||||||
|
get autenticado() {
|
||||||
|
return !!this.state.usuario && !!this.state.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isAdmin() {
|
||||||
|
return this.state.usuario?.role.nivel === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isTI() {
|
||||||
|
return this.state.usuario?.role.nome === "ti" || this.isAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isRH() {
|
||||||
|
return this.state.usuario?.role.nome === "rh" || this.isAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
login(usuario: Usuario, token: string) {
|
||||||
|
this.state.usuario = usuario;
|
||||||
|
this.state.token = token;
|
||||||
|
this.state.carregando = false;
|
||||||
|
|
||||||
|
if (browser) {
|
||||||
|
localStorage.setItem("auth_token", token);
|
||||||
|
localStorage.setItem("auth_usuario", JSON.stringify(usuario));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
this.state.usuario = null;
|
||||||
|
this.state.token = null;
|
||||||
|
this.state.carregando = false;
|
||||||
|
|
||||||
|
if (browser) {
|
||||||
|
localStorage.removeItem("auth_token");
|
||||||
|
localStorage.removeItem("auth_usuario");
|
||||||
|
goto("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCarregando(carregando: boolean) {
|
||||||
|
this.state.carregando = carregando;
|
||||||
|
}
|
||||||
|
|
||||||
|
private carregarDoLocalStorage() {
|
||||||
|
const token = localStorage.getItem("auth_token");
|
||||||
|
const usuarioStr = localStorage.getItem("auth_usuario");
|
||||||
|
|
||||||
|
if (token && usuarioStr) {
|
||||||
|
try {
|
||||||
|
const usuario = JSON.parse(usuarioStr);
|
||||||
|
this.state.usuario = usuario;
|
||||||
|
this.state.token = token;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao carregar usuário do localStorage:", error);
|
||||||
|
this.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.carregando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authStore = new AuthStore();
|
||||||
|
|
||||||
22
apps/web/src/lib/stores/loginModal.svelte.ts
Normal file
22
apps/web/src/lib/stores/loginModal.svelte.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { browser } from "$app/environment";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store global para controlar o modal de login
|
||||||
|
*/
|
||||||
|
class LoginModalStore {
|
||||||
|
showModal = $state(false);
|
||||||
|
redirectAfterLogin = $state<string | null>(null);
|
||||||
|
|
||||||
|
open(redirectTo?: string) {
|
||||||
|
this.showModal = true;
|
||||||
|
this.redirectAfterLogin = redirectTo || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.showModal = false;
|
||||||
|
this.redirectAfterLogin = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loginModalStore = new LoginModalStore();
|
||||||
|
|
||||||
@@ -1,7 +1,72 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import MenuProtection from "$lib/components/MenuProtection.svelte";
|
||||||
|
|
||||||
const { children } = $props();
|
const { children } = $props();
|
||||||
|
|
||||||
|
// Mapa de rotas para verificação de permissões
|
||||||
|
const ROUTE_PERMISSIONS: Record<string, { path: string; requireGravar?: boolean }> = {
|
||||||
|
// Recursos Humanos
|
||||||
|
"/recursos-humanos": { path: "/recursos-humanos" },
|
||||||
|
"/recursos-humanos/funcionarios": { path: "/recursos-humanos/funcionarios" },
|
||||||
|
"/recursos-humanos/funcionarios/cadastro": { path: "/recursos-humanos/funcionarios", requireGravar: true },
|
||||||
|
"/recursos-humanos/funcionarios/excluir": { path: "/recursos-humanos/funcionarios", requireGravar: true },
|
||||||
|
"/recursos-humanos/funcionarios/relatorios": { path: "/recursos-humanos/funcionarios" },
|
||||||
|
"/recursos-humanos/simbolos": { path: "/recursos-humanos/simbolos" },
|
||||||
|
"/recursos-humanos/simbolos/cadastro": { path: "/recursos-humanos/simbolos", requireGravar: true },
|
||||||
|
// Outros menus
|
||||||
|
"/financeiro": { path: "/financeiro" },
|
||||||
|
"/controladoria": { path: "/controladoria" },
|
||||||
|
"/licitacoes": { path: "/licitacoes" },
|
||||||
|
"/compras": { path: "/compras" },
|
||||||
|
"/juridico": { path: "/juridico" },
|
||||||
|
"/comunicacao": { path: "/comunicacao" },
|
||||||
|
"/programas-esportivos": { path: "/programas-esportivos" },
|
||||||
|
"/secretaria-executiva": { path: "/secretaria-executiva" },
|
||||||
|
"/gestao-pessoas": { path: "/gestao-pessoas" },
|
||||||
|
"/ti": { path: "/ti" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Obter configuração para a rota atual
|
||||||
|
const getCurrentRouteConfig = $derived.by(() => {
|
||||||
|
const currentPath = page.url.pathname;
|
||||||
|
|
||||||
|
// Verificar correspondência exata
|
||||||
|
if (ROUTE_PERMISSIONS[currentPath]) {
|
||||||
|
return ROUTE_PERMISSIONS[currentPath];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar rotas dinâmicas (com [id])
|
||||||
|
if (currentPath.includes("/editar") || currentPath.includes("/funcionarioId") || currentPath.includes("/simboloId")) {
|
||||||
|
// Extrair o caminho base
|
||||||
|
if (currentPath.includes("/funcionarios/")) {
|
||||||
|
return { path: "/recursos-humanos/funcionarios", requireGravar: true };
|
||||||
|
}
|
||||||
|
if (currentPath.includes("/simbolos/")) {
|
||||||
|
return { path: "/recursos-humanos/simbolos", requireGravar: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotas públicas (Dashboard, Solicitar Acesso, etc)
|
||||||
|
if (currentPath === "/" || currentPath === "/solicitar-acesso") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Para qualquer outra rota dentro do dashboard, verificar o primeiro segmento
|
||||||
|
const segments = currentPath.split("/").filter(Boolean);
|
||||||
|
if (segments.length > 0) {
|
||||||
|
const firstSegment = "/" + segments[0];
|
||||||
|
if (ROUTE_PERMISSIONS[firstSegment]) {
|
||||||
|
return ROUTE_PERMISSIONS[firstSegment];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if getCurrentRouteConfig}
|
||||||
|
<MenuProtection menuPath={getCurrentRouteConfig.path} requireGravar={getCurrentRouteConfig.requireGravar || false}>
|
||||||
<div class="w-full h-full overflow-y-auto">
|
<div class="w-full h-full overflow-y-auto">
|
||||||
<main
|
<main
|
||||||
id="container-central"
|
id="container-central"
|
||||||
@@ -10,3 +75,14 @@
|
|||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,8 +1,582 @@
|
|||||||
<div class="space-y-4">
|
<script lang="ts">
|
||||||
<h2 class="text-2xl font-bold text-brand-dark">Dashboard</h2>
|
import { useQuery } from "convex-svelte";
|
||||||
<div class="grid md:grid-cols-3 gap-4">
|
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||||
<div class="p-4 rounded-xl border">Bem-vindo ao SGSE.</div>
|
import { onMount } from "svelte";
|
||||||
<div class="p-4 rounded-xl border">Selecione um setor no menu lateral.</div>
|
import { page } from "$app/stores";
|
||||||
<div class="p-4 rounded-xl border">KPIs e gráficos virão aqui.</div>
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
// Queries para dados do dashboard
|
||||||
|
const statsQuery = useQuery(api.dashboard.getStats, {});
|
||||||
|
const activityQuery = useQuery(api.dashboard.getRecentActivity, {});
|
||||||
|
|
||||||
|
// Queries para monitoramento em tempo real
|
||||||
|
const statusSistemaQuery = useQuery(api.monitoramento.getStatusSistema, {});
|
||||||
|
const atividadeBDQuery = useQuery(api.monitoramento.getAtividadeBancoDados, {});
|
||||||
|
const distribuicaoQuery = useQuery(api.monitoramento.getDistribuicaoRequisicoes, {});
|
||||||
|
|
||||||
|
// Estado para animações
|
||||||
|
let mounted = $state(false);
|
||||||
|
let currentTime = $state(new Date());
|
||||||
|
let showAlert = $state(false);
|
||||||
|
let alertType = $state<"auth_required" | "access_denied" | "invalid_token" | null>(null);
|
||||||
|
let redirectRoute = $state("");
|
||||||
|
|
||||||
|
// Forçar atualização das queries de monitoramento a cada 1 segundo
|
||||||
|
let refreshKey = $state(0);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
mounted = true;
|
||||||
|
|
||||||
|
// Verificar se há mensagem de erro na URL
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const error = urlParams.get("error");
|
||||||
|
const route = urlParams.get("route") || urlParams.get("redirect") || "";
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
alertType = error as any;
|
||||||
|
redirectRoute = route;
|
||||||
|
showAlert = true;
|
||||||
|
|
||||||
|
// Limpar URL
|
||||||
|
const newUrl = window.location.pathname;
|
||||||
|
window.history.replaceState({}, "", newUrl);
|
||||||
|
|
||||||
|
// Auto-fechar após 10 segundos
|
||||||
|
setTimeout(() => {
|
||||||
|
showAlert = false;
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualizar relógio e forçar refresh das queries a cada segundo
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
currentTime = new Date();
|
||||||
|
refreshKey = (refreshKey + 1) % 1000; // Incrementar para forçar re-render
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
});
|
||||||
|
|
||||||
|
function closeAlert() {
|
||||||
|
showAlert = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlertMessage(): { title: string; message: string; icon: string } {
|
||||||
|
switch (alertType) {
|
||||||
|
case "auth_required":
|
||||||
|
return {
|
||||||
|
title: "Autenticação Necessária",
|
||||||
|
message: `Para acessar "${redirectRoute}", você precisa fazer login no sistema.`,
|
||||||
|
icon: "🔐"
|
||||||
|
};
|
||||||
|
case "access_denied":
|
||||||
|
return {
|
||||||
|
title: "Acesso Negado",
|
||||||
|
message: `Você não tem permissão para acessar "${redirectRoute}". Entre em contato com a equipe de TI para solicitar acesso.`,
|
||||||
|
icon: "⛔"
|
||||||
|
};
|
||||||
|
case "invalid_token":
|
||||||
|
return {
|
||||||
|
title: "Sessão Expirada",
|
||||||
|
message: "Sua sessão expirou. Por favor, faça login novamente.",
|
||||||
|
icon: "⏰"
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
title: "Aviso",
|
||||||
|
message: "Ocorreu um erro. Tente novamente.",
|
||||||
|
icon: "⚠️"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função para formatar números
|
||||||
|
function formatNumber(num: number): string {
|
||||||
|
return new Intl.NumberFormat("pt-BR").format(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função para calcular porcentagem
|
||||||
|
function calcPercentage(value: number, total: number): number {
|
||||||
|
if (total === 0) return 0;
|
||||||
|
return Math.round((value / total) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obter saudação baseada na hora
|
||||||
|
function getSaudacao(): string {
|
||||||
|
const hora = currentTime.getHours();
|
||||||
|
if (hora < 12) return "Bom dia";
|
||||||
|
if (hora < 18) return "Boa tarde";
|
||||||
|
return "Boa noite";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-4">
|
||||||
|
<!-- Alerta de Acesso Negado / Autenticação -->
|
||||||
|
{#if showAlert}
|
||||||
|
{@const alertData = getAlertMessage()}
|
||||||
|
<div class="alert {alertType === 'access_denied' ? 'alert-error' : alertType === 'auth_required' ? 'alert-warning' : 'alert-info'} mb-6 shadow-xl animate-pulse">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<span class="text-4xl">{alertData.icon}</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="font-bold text-lg mb-1">{alertData.title}</h3>
|
||||||
|
<p class="text-sm">{alertData.message}</p>
|
||||||
|
{#if alertType === "access_denied"}
|
||||||
|
<div class="mt-3 flex gap-2">
|
||||||
|
<a href="/solicitar-acesso" class="btn btn-sm 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="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>
|
||||||
|
Solicitar Acesso
|
||||||
|
</a>
|
||||||
|
<a href="/ti" class="btn btn-sm btn-ghost">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 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>
|
||||||
|
Contatar TI
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-circle btn-ghost" onclick={closeAlert}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Cabeçalho com Boas-vindas -->
|
||||||
|
<div class="bg-gradient-to-r from-primary/20 to-secondary/20 rounded-2xl p-8 mb-6 shadow-lg">
|
||||||
|
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-4xl font-bold text-primary mb-2">
|
||||||
|
{getSaudacao()}! 👋
|
||||||
|
</h1>
|
||||||
|
<p class="text-xl text-base-content/80">
|
||||||
|
Bem-vindo ao Sistema de Gerenciamento da Secretaria de Esportes
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-base-content/60 mt-2">
|
||||||
|
{currentTime.toLocaleDateString("pt-BR", {
|
||||||
|
weekday: "long",
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
{" - "}
|
||||||
|
{currentTime.toLocaleTimeString("pt-BR")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="badge badge-primary badge-lg">Sistema Online</div>
|
||||||
|
<div class="badge badge-success badge-lg">Atualizado</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cards de Estatísticas Principais -->
|
||||||
|
{#if statsQuery.isLoading}
|
||||||
|
<div class="flex justify-center items-center py-12">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
{:else if statsQuery.data}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||||
|
<!-- Total de Funcionários -->
|
||||||
|
<div class="card bg-gradient-to-br from-blue-500/10 to-blue-600/20 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-base-content/70 font-semibold">Total de Funcionários</p>
|
||||||
|
<h2 class="text-4xl font-bold text-primary mt-2">
|
||||||
|
{formatNumber(statsQuery.data.totalFuncionarios)}
|
||||||
|
</h2>
|
||||||
|
<p class="text-xs text-base-content/60 mt-1">
|
||||||
|
{statsQuery.data.funcionariosAtivos} ativos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="radial-progress text-primary" style="--value:{calcPercentage(statsQuery.data.funcionariosAtivos, statsQuery.data.totalFuncionarios)}; --size:4rem;">
|
||||||
|
<span class="text-xs font-bold">{calcPercentage(statsQuery.data.funcionariosAtivos, statsQuery.data.totalFuncionarios)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Solicitações Pendentes -->
|
||||||
|
<div class="card bg-gradient-to-br from-yellow-500/10 to-yellow-600/20 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-base-content/70 font-semibold">Solicitações Pendentes</p>
|
||||||
|
<h2 class="text-4xl font-bold text-warning mt-2">
|
||||||
|
{formatNumber(statsQuery.data.solicitacoesPendentes)}
|
||||||
|
</h2>
|
||||||
|
<p class="text-xs text-base-content/60 mt-1">
|
||||||
|
de {statsQuery.data.totalSolicitacoesAcesso} total
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 bg-warning/20 rounded-full">
|
||||||
|
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Símbolos Cadastrados -->
|
||||||
|
<div class="card bg-gradient-to-br from-green-500/10 to-green-600/20 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-base-content/70 font-semibold">Símbolos Cadastrados</p>
|
||||||
|
<h2 class="text-4xl font-bold text-success mt-2">
|
||||||
|
{formatNumber(statsQuery.data.totalSimbolos)}
|
||||||
|
</h2>
|
||||||
|
<p class="text-xs text-base-content/60 mt-1">
|
||||||
|
{statsQuery.data.cargoComissionado} CC / {statsQuery.data.funcaoGratificada} FG
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 bg-success/20 rounded-full">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Atividade 24h -->
|
||||||
|
{#if activityQuery.data}
|
||||||
|
<div class="card bg-gradient-to-br from-purple-500/10 to-purple-600/20 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-base-content/70 font-semibold">Atividade (24h)</p>
|
||||||
|
<h2 class="text-4xl font-bold text-secondary mt-2">
|
||||||
|
{formatNumber(activityQuery.data.funcionariosCadastrados24h + activityQuery.data.solicitacoesAcesso24h)}
|
||||||
|
</h2>
|
||||||
|
<p class="text-xs text-base-content/60 mt-1">
|
||||||
|
{activityQuery.data.funcionariosCadastrados24h} cadastros
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 bg-secondary/20 rounded-full">
|
||||||
|
<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="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Monitoramento em Tempo Real -->
|
||||||
|
{#if statusSistemaQuery.data && atividadeBDQuery.data && distribuicaoQuery.data}
|
||||||
|
{@const status = statusSistemaQuery.data}
|
||||||
|
{@const atividade = atividadeBDQuery.data}
|
||||||
|
{@const distribuicao = distribuicaoQuery.data}
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<div class="p-2 bg-error/10 rounded-lg animate-pulse">
|
||||||
|
<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="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-base-content">Monitoramento em Tempo Real</h2>
|
||||||
|
<p class="text-sm text-base-content/60">
|
||||||
|
Atualizado a cada segundo • {new Date(status.ultimaAtualizacao).toLocaleTimeString('pt-BR')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="ml-auto badge badge-error badge-lg gap-2">
|
||||||
|
<span class="animate-ping absolute inline-flex h-3 w-3 rounded-full bg-error opacity-75"></span>
|
||||||
|
<span class="relative inline-flex rounded-full h-3 w-3 bg-error"></span>
|
||||||
|
LIVE
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cards de Status do Sistema -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<!-- Usuários Online -->
|
||||||
|
<div class="card bg-gradient-to-br from-primary/10 to-primary/5 border-2 border-primary/20 shadow-lg">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/70 font-semibold uppercase">Usuários Online</p>
|
||||||
|
<h3 class="text-3xl font-bold text-primary mt-1">{status.usuariosOnline}</h3>
|
||||||
|
<p class="text-xs text-base-content/60 mt-1">sessões ativas</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-primary/20 rounded-full">
|
||||||
|
<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="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Total de Registros -->
|
||||||
|
<div class="card bg-gradient-to-br from-success/10 to-success/5 border-2 border-success/20 shadow-lg">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/70 font-semibold uppercase">Total Registros</p>
|
||||||
|
<h3 class="text-3xl font-bold text-success mt-1">{status.totalRegistros.toLocaleString('pt-BR')}</h3>
|
||||||
|
<p class="text-xs text-base-content/60 mt-1">no banco de dados</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-success/20 rounded-full">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tempo Médio de Resposta -->
|
||||||
|
<div class="card bg-gradient-to-br from-info/10 to-info/5 border-2 border-info/20 shadow-lg">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/70 font-semibold uppercase">Tempo Resposta</p>
|
||||||
|
<h3 class="text-3xl font-bold text-info mt-1">{status.tempoMedioResposta}ms</h3>
|
||||||
|
<p class="text-xs text-base-content/60 mt-1">média atual</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-info/20 rounded-full">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-info" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Uso de Sistema -->
|
||||||
|
<div class="card bg-gradient-to-br from-warning/10 to-warning/5 border-2 border-warning/20 shadow-lg">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/70 font-semibold uppercase mb-2">Uso do Sistema</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between text-xs mb-1">
|
||||||
|
<span class="text-base-content/70">CPU</span>
|
||||||
|
<span class="font-bold text-warning">{status.cpuUsada}%</span>
|
||||||
|
</div>
|
||||||
|
<progress class="progress progress-warning w-full" value={status.cpuUsada} max="100"></progress>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between text-xs mb-1">
|
||||||
|
<span class="text-base-content/70">Memória</span>
|
||||||
|
<span class="font-bold text-warning">{status.memoriaUsada}%</span>
|
||||||
|
</div>
|
||||||
|
<progress class="progress progress-warning w-full" value={status.memoriaUsada} max="100"></progress>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gráfico de Atividade do Banco de Dados em Tempo Real -->
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-bold text-base-content">Atividade do Banco de Dados</h3>
|
||||||
|
<p class="text-sm text-base-content/60">Entradas e saídas em tempo real (último minuto)</p>
|
||||||
|
</div>
|
||||||
|
<div class="badge badge-success gap-2">
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
Atualizando
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative h-64">
|
||||||
|
<!-- Eixo Y -->
|
||||||
|
<div class="absolute left-0 top-0 bottom-8 w-10 flex flex-col justify-between text-right pr-2">
|
||||||
|
{#each [10, 8, 6, 4, 2, 0] as val}
|
||||||
|
<span class="text-xs text-base-content/60">{val}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid e Barras -->
|
||||||
|
<div class="absolute left-12 right-4 top-0 bottom-8">
|
||||||
|
<!-- Grid horizontal -->
|
||||||
|
{#each Array.from({length: 6}) as _, i}
|
||||||
|
<div class="absolute left-0 right-0 border-t border-base-content/10" style="top: {(i / 5) * 100}%;"></div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Barras de atividade -->
|
||||||
|
<div class="flex items-end justify-around h-full gap-1">
|
||||||
|
{#each atividade.historico as ponto, idx}
|
||||||
|
{@const maxAtividade = Math.max(...atividade.historico.map(p => Math.max(p.entradas, p.saidas)))}
|
||||||
|
<div class="flex-1 flex items-end gap-0.5 h-full group">
|
||||||
|
<!-- Entradas (verde) -->
|
||||||
|
<div
|
||||||
|
class="flex-1 bg-gradient-to-t from-success to-success/70 rounded-t transition-all duration-300 hover:scale-110"
|
||||||
|
style="height: {ponto.entradas / Math.max(maxAtividade, 1) * 100}%; min-height: 2px;"
|
||||||
|
title="Entradas: {ponto.entradas}"
|
||||||
|
></div>
|
||||||
|
<!-- Saídas (vermelho) -->
|
||||||
|
<div
|
||||||
|
class="flex-1 bg-gradient-to-t from-error to-error/70 rounded-t transition-all duration-300 hover:scale-110"
|
||||||
|
style="height: {ponto.saidas / Math.max(maxAtividade, 1) * 100}%; min-height: 2px;"
|
||||||
|
title="Saídas: {ponto.saidas}"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Tooltip no hover -->
|
||||||
|
<div class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity bg-base-300 text-base-content px-2 py-1 rounded text-xs whitespace-nowrap shadow-lg z-10">
|
||||||
|
<div>↑ {ponto.entradas} entradas</div>
|
||||||
|
<div>↓ {ponto.saidas} saídas</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Linha do eixo X -->
|
||||||
|
<div class="absolute left-12 right-4 bottom-8 border-t-2 border-base-content/30"></div>
|
||||||
|
|
||||||
|
<!-- Labels do eixo X -->
|
||||||
|
<div class="absolute left-12 right-4 bottom-0 flex justify-between text-xs text-base-content/60">
|
||||||
|
<span>-60s</span>
|
||||||
|
<span>-30s</span>
|
||||||
|
<span>agora</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legenda -->
|
||||||
|
<div class="flex justify-center gap-6 mt-4 pt-4 border-t border-base-300">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-4 h-4 bg-gradient-to-t from-success to-success/70 rounded"></div>
|
||||||
|
<span class="text-sm text-base-content/70">Entradas no BD</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-4 h-4 bg-gradient-to-t from-error to-error/70 rounded"></div>
|
||||||
|
<span class="text-sm text-base-content/70">Saídas do BD</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Distribuição de Requisições -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="text-lg font-bold text-base-content mb-4">Tipos de Operações</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between text-sm mb-1">
|
||||||
|
<span>Queries (Leituras)</span>
|
||||||
|
<span class="font-bold text-primary">{distribuicao.queries}</span>
|
||||||
|
</div>
|
||||||
|
<progress class="progress progress-primary w-full" value={distribuicao.queries} max={distribuicao.queries + distribuicao.mutations}></progress>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between text-sm mb-1">
|
||||||
|
<span>Mutations (Escritas)</span>
|
||||||
|
<span class="font-bold text-secondary">{distribuicao.mutations}</span>
|
||||||
|
</div>
|
||||||
|
<progress class="progress progress-secondary w-full" value={distribuicao.mutations} max={distribuicao.queries + distribuicao.mutations}></progress>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="text-lg font-bold text-base-content mb-4">Operações no Banco</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between text-sm mb-1">
|
||||||
|
<span>Leituras</span>
|
||||||
|
<span class="font-bold text-info">{distribuicao.leituras}</span>
|
||||||
|
</div>
|
||||||
|
<progress class="progress progress-info w-full" value={distribuicao.leituras} max={distribuicao.leituras + distribuicao.escritas}></progress>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between text-sm mb-1">
|
||||||
|
<span>Escritas</span>
|
||||||
|
<span class="font-bold text-warning">{distribuicao.escritas}</span>
|
||||||
|
</div>
|
||||||
|
<progress class="progress progress-warning w-full" value={distribuicao.escritas} max={distribuicao.leituras + distribuicao.escritas}></progress>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Cards de Status -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">Status do Sistema</h3>
|
||||||
|
<div class="space-y-2 mt-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm">Banco de Dados</span>
|
||||||
|
<span class="badge badge-success">Online</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm">API</span>
|
||||||
|
<span class="badge badge-success">Operacional</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm">Backup</span>
|
||||||
|
<span class="badge badge-success">Atualizado</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">Acesso Rápido</h3>
|
||||||
|
<div class="space-y-2 mt-4">
|
||||||
|
<a href="/recursos-humanos/funcionarios/cadastro" class="btn btn-sm btn-primary w-full">
|
||||||
|
Novo Funcionário
|
||||||
|
</a>
|
||||||
|
<a href="/recursos-humanos/simbolos/cadastro" class="btn btn-sm btn-primary w-full">
|
||||||
|
Novo Símbolo
|
||||||
|
</a>
|
||||||
|
<a href="/ti/painel-administrativo" class="btn btn-sm btn-primary w-full">
|
||||||
|
Painel Admin
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">Informações</h3>
|
||||||
|
<div class="space-y-2 mt-4 text-sm">
|
||||||
|
<p class="text-base-content/70">
|
||||||
|
<strong>Versão:</strong> 1.0.0
|
||||||
|
</p>
|
||||||
|
<p class="text-base-content/70">
|
||||||
|
<strong>Última Atualização:</strong> {new Date().toLocaleDateString("pt-BR")}
|
||||||
|
</p>
|
||||||
|
<p class="text-base-content/70">
|
||||||
|
<strong>Suporte:</strong> TI SGSE
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
animation: fadeIn 0.5s ease-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
371
apps/web/src/routes/(dashboard)/alterar-senha/+page.svelte
Normal file
371
apps/web/src/routes/(dashboard)/alterar-senha/+page.svelte
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useConvexClient } from "convex-svelte";
|
||||||
|
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||||
|
import { authStore } from "$lib/stores/auth.svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
const convex = useConvexClient();
|
||||||
|
|
||||||
|
let senhaAtual = $state("");
|
||||||
|
let novaSenha = $state("");
|
||||||
|
let confirmarSenha = $state("");
|
||||||
|
let carregando = $state(false);
|
||||||
|
let notice = $state<{ type: "success" | "error"; message: string } | null>(null);
|
||||||
|
let mostrarSenhaAtual = $state(false);
|
||||||
|
let mostrarNovaSenha = $state(false);
|
||||||
|
let mostrarConfirmarSenha = $state(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!authStore.autenticado) {
|
||||||
|
goto("/");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function validarSenha(senha: string): { valido: boolean; erros: string[] } {
|
||||||
|
const erros: string[] = [];
|
||||||
|
|
||||||
|
if (senha.length < 8) {
|
||||||
|
erros.push("A senha deve ter no mínimo 8 caracteres");
|
||||||
|
}
|
||||||
|
if (!/[A-Z]/.test(senha)) {
|
||||||
|
erros.push("A senha deve conter pelo menos uma letra maiúscula");
|
||||||
|
}
|
||||||
|
if (!/[a-z]/.test(senha)) {
|
||||||
|
erros.push("A senha deve conter pelo menos uma letra minúscula");
|
||||||
|
}
|
||||||
|
if (!/[0-9]/.test(senha)) {
|
||||||
|
erros.push("A senha deve conter pelo menos um número");
|
||||||
|
}
|
||||||
|
if (!/[!@#$%^&*(),.?":{}|<>]/.test(senha)) {
|
||||||
|
erros.push("A senha deve conter pelo menos um caractere especial");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valido: erros.length === 0,
|
||||||
|
erros,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
notice = null;
|
||||||
|
|
||||||
|
// Validações
|
||||||
|
if (!senhaAtual || !novaSenha || !confirmarSenha) {
|
||||||
|
notice = {
|
||||||
|
type: "error",
|
||||||
|
message: "Todos os campos são obrigatórios",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (novaSenha !== confirmarSenha) {
|
||||||
|
notice = {
|
||||||
|
type: "error",
|
||||||
|
message: "A nova senha e a confirmação não coincidem",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (senhaAtual === novaSenha) {
|
||||||
|
notice = {
|
||||||
|
type: "error",
|
||||||
|
message: "A nova senha deve ser diferente da senha atual",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validacao = validarSenha(novaSenha);
|
||||||
|
if (!validacao.valido) {
|
||||||
|
notice = {
|
||||||
|
type: "error",
|
||||||
|
message: validacao.erros.join(". "),
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
carregando = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!authStore.token) {
|
||||||
|
throw new Error("Token não encontrado");
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultado = await convex.mutation(api.autenticacao.alterarSenha, {
|
||||||
|
token: authStore.token,
|
||||||
|
senhaAntiga: senhaAtual,
|
||||||
|
novaSenha: novaSenha,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resultado.sucesso) {
|
||||||
|
notice = {
|
||||||
|
type: "success",
|
||||||
|
message: "Senha alterada com sucesso! Redirecionando...",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Limpar campos
|
||||||
|
senhaAtual = "";
|
||||||
|
novaSenha = "";
|
||||||
|
confirmarSenha = "";
|
||||||
|
|
||||||
|
// Redirecionar após 2 segundos
|
||||||
|
setTimeout(() => {
|
||||||
|
goto("/");
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
notice = {
|
||||||
|
type: "error",
|
||||||
|
message: resultado.erro || "Erro ao alterar senha",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
notice = {
|
||||||
|
type: "error",
|
||||||
|
message: error.message || "Erro ao conectar com o servidor",
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
carregando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelar() {
|
||||||
|
goto("/");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-8 max-w-2xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<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="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>
|
||||||
|
<h1 class="text-4xl font-bold text-primary">Alterar Senha</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-base-content/70 text-lg">
|
||||||
|
Atualize sua senha de acesso ao sistema
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Breadcrumbs -->
|
||||||
|
<div class="text-sm breadcrumbs mb-6">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">Dashboard</a></li>
|
||||||
|
<li>Alterar Senha</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alertas -->
|
||||||
|
{#if notice}
|
||||||
|
<div class="alert {notice.type === 'success' ? 'alert-success' : 'alert-error'} 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"
|
||||||
|
>
|
||||||
|
{#if notice.type === "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>{notice.message}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Formulário -->
|
||||||
|
<div class="card bg-base-100 shadow-xl border border-base-300">
|
||||||
|
<div class="card-body">
|
||||||
|
<form onsubmit={handleSubmit} class="space-y-6">
|
||||||
|
<!-- Senha Atual -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="senha-atual">
|
||||||
|
<span class="label-text font-semibold">Senha Atual</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
id="senha-atual"
|
||||||
|
type={mostrarSenhaAtual ? "text" : "password"}
|
||||||
|
placeholder="Digite sua senha atual"
|
||||||
|
class="input input-bordered input-primary w-full pr-12"
|
||||||
|
bind:value={senhaAtual}
|
||||||
|
required
|
||||||
|
disabled={carregando}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-sm btn-circle"
|
||||||
|
onclick={() => (mostrarSenhaAtual = !mostrarSenhaAtual)}
|
||||||
|
>
|
||||||
|
{#if mostrarSenhaAtual}
|
||||||
|
<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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||||
|
</svg>
|
||||||
|
{: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="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>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nova Senha -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="nova-senha">
|
||||||
|
<span class="label-text font-semibold">Nova Senha</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
id="nova-senha"
|
||||||
|
type={mostrarNovaSenha ? "text" : "password"}
|
||||||
|
placeholder="Digite sua nova senha"
|
||||||
|
class="input input-bordered input-primary w-full pr-12"
|
||||||
|
bind:value={novaSenha}
|
||||||
|
required
|
||||||
|
disabled={carregando}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-sm btn-circle"
|
||||||
|
onclick={() => (mostrarNovaSenha = !mostrarNovaSenha)}
|
||||||
|
>
|
||||||
|
{#if mostrarNovaSenha}
|
||||||
|
<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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||||
|
</svg>
|
||||||
|
{: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="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>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text-alt text-base-content/60">
|
||||||
|
Mínimo 8 caracteres, com letras maiúsculas, minúsculas, números e caracteres especiais
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirmar Senha -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="confirmar-senha">
|
||||||
|
<span class="label-text font-semibold">Confirmar Nova Senha</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
id="confirmar-senha"
|
||||||
|
type={mostrarConfirmarSenha ? "text" : "password"}
|
||||||
|
placeholder="Digite novamente sua nova senha"
|
||||||
|
class="input input-bordered input-primary w-full pr-12"
|
||||||
|
bind:value={confirmarSenha}
|
||||||
|
required
|
||||||
|
disabled={carregando}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-sm btn-circle"
|
||||||
|
onclick={() => (mostrarConfirmarSenha = !mostrarConfirmarSenha)}
|
||||||
|
>
|
||||||
|
{#if mostrarConfirmarSenha}
|
||||||
|
<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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||||
|
</svg>
|
||||||
|
{: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="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>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Requisitos de Senha -->
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<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">Requisitos de Senha:</h3>
|
||||||
|
<ul class="text-sm list-disc list-inside mt-2 space-y-1">
|
||||||
|
<li>Mínimo de 8 caracteres</li>
|
||||||
|
<li>Pelo menos uma letra maiúscula (A-Z)</li>
|
||||||
|
<li>Pelo menos uma letra minúscula (a-z)</li>
|
||||||
|
<li>Pelo menos um número (0-9)</li>
|
||||||
|
<li>Pelo menos um caractere especial (!@#$%^&*...)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botões -->
|
||||||
|
<div class="flex gap-4 justify-end mt-8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost"
|
||||||
|
onclick={cancelar}
|
||||||
|
disabled={carregando}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled={carregando}
|
||||||
|
>
|
||||||
|
{#if carregando}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
Alterando...
|
||||||
|
{: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>
|
||||||
|
Alterar Senha
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dicas de Segurança -->
|
||||||
|
<div class="mt-6 card bg-base-200 shadow-lg">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-warning" 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>
|
||||||
|
Dicas de Segurança
|
||||||
|
</h3>
|
||||||
|
<ul class="text-sm space-y-2 text-base-content/70">
|
||||||
|
<li>✅ Nunca compartilhe sua senha com ninguém</li>
|
||||||
|
<li>✅ Use uma senha única para cada sistema</li>
|
||||||
|
<li>✅ Altere sua senha regularmente</li>
|
||||||
|
<li>✅ Não use informações pessoais óbvias (nome, data de nascimento, etc.)</li>
|
||||||
|
<li>✅ Considere usar um gerenciador de senhas</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
48
apps/web/src/routes/(dashboard)/compras/+page.svelte
Normal file
48
apps/web/src/routes/(dashboard)/compras/+page.svelte
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-4">
|
||||||
|
<div class="text-sm breadcrumbs mb-4">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/" class="text-primary hover:underline">Dashboard</a></li>
|
||||||
|
<li>Compras</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-4 mb-2">
|
||||||
|
<div class="p-3 bg-cyan-500/20 rounded-xl">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-cyan-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-primary">Compras</h1>
|
||||||
|
<p class="text-base-content/70">Gestão de compras e aquisições</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div class="mb-6">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 text-base-content/20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
|
||||||
|
<p class="text-base-content/70 max-w-md mb-6">
|
||||||
|
O módulo de Compras está sendo desenvolvido e em breve estará disponível com funcionalidades completas para gestão de compras e aquisições.
|
||||||
|
</p>
|
||||||
|
<div class="badge badge-warning badge-lg gap-2">
|
||||||
|
<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 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
|
</svg>
|
||||||
|
Em Desenvolvimento
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
48
apps/web/src/routes/(dashboard)/comunicacao/+page.svelte
Normal file
48
apps/web/src/routes/(dashboard)/comunicacao/+page.svelte
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-4">
|
||||||
|
<div class="text-sm breadcrumbs mb-4">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/" class="text-primary hover:underline">Dashboard</a></li>
|
||||||
|
<li>Comunicação</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-4 mb-2">
|
||||||
|
<div class="p-3 bg-pink-500/20 rounded-xl">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-pink-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-primary">Comunicação</h1>
|
||||||
|
<p class="text-base-content/70">Gestão de comunicação institucional</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div class="mb-6">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 text-base-content/20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
|
||||||
|
<p class="text-base-content/70 max-w-md mb-6">
|
||||||
|
O módulo de Comunicação está sendo desenvolvido e em breve estará disponível com funcionalidades completas de gestão de comunicação institucional.
|
||||||
|
</p>
|
||||||
|
<div class="badge badge-warning badge-lg gap-2">
|
||||||
|
<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 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
|
</svg>
|
||||||
|
Em Desenvolvimento
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
99
apps/web/src/routes/(dashboard)/controladoria/+page.svelte
Normal file
99
apps/web/src/routes/(dashboard)/controladoria/+page.svelte
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-4">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="text-sm breadcrumbs mb-4">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/" class="text-primary hover:underline">Dashboard</a></li>
|
||||||
|
<li>Controladoria</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cabeçalho -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-4 mb-2">
|
||||||
|
<div class="p-3 bg-purple-500/20 rounded-xl">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-primary">Controladoria</h1>
|
||||||
|
<p class="text-base-content/70">Controle e auditoria interna da secretaria</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card de Aviso -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div class="mb-6">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 text-base-content/20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
|
||||||
|
<p class="text-base-content/70 max-w-md mb-6">
|
||||||
|
O módulo de Controladoria está sendo desenvolvido e em breve estará disponível com funcionalidades completas de controle e auditoria.
|
||||||
|
</p>
|
||||||
|
<div class="badge badge-warning badge-lg gap-2">
|
||||||
|
<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 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
|
</svg>
|
||||||
|
Em Desenvolvimento
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Funcionalidades Previstas -->
|
||||||
|
<div class="mt-6">
|
||||||
|
<h3 class="text-xl font-bold mb-4">Funcionalidades Previstas</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h4 class="font-semibold">Auditoria Interna</h4>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-base-content/70">Controle e verificação de processos internos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h4 class="font-semibold">Compliance</h4>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-base-content/70">Conformidade com normas e regulamentos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<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="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h4 class="font-semibold">Indicadores de Gestão</h4>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-base-content/70">Monitoramento de KPIs e métricas</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
264
apps/web/src/routes/(dashboard)/esqueci-senha/+page.svelte
Normal file
264
apps/web/src/routes/(dashboard)/esqueci-senha/+page.svelte
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useConvexClient } from "convex-svelte";
|
||||||
|
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||||
|
|
||||||
|
const convex = useConvexClient();
|
||||||
|
|
||||||
|
let matricula = $state("");
|
||||||
|
let email = $state("");
|
||||||
|
let carregando = $state(false);
|
||||||
|
let notice = $state<{ type: "success" | "error" | "info"; message: string } | null>(null);
|
||||||
|
let solicitacaoEnviada = $state(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
notice = null;
|
||||||
|
|
||||||
|
if (!matricula || !email) {
|
||||||
|
notice = {
|
||||||
|
type: "error",
|
||||||
|
message: "Por favor, preencha todos os campos",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
carregando = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verificar se o usuário existe
|
||||||
|
const usuarios = await convex.query(api.usuarios.listar, {
|
||||||
|
matricula: matricula,
|
||||||
|
});
|
||||||
|
|
||||||
|
const usuario = usuarios.find(u => u.matricula === matricula && u.email === email);
|
||||||
|
|
||||||
|
if (!usuario) {
|
||||||
|
notice = {
|
||||||
|
type: "error",
|
||||||
|
message: "Matrícula ou e-mail não encontrados. Verifique os dados e tente novamente.",
|
||||||
|
};
|
||||||
|
carregando = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simular envio de solicitação
|
||||||
|
solicitacaoEnviada = true;
|
||||||
|
notice = {
|
||||||
|
type: "success",
|
||||||
|
message: "Solicitação enviada com sucesso! A equipe de TI entrará em contato em breve.",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Limpar campos
|
||||||
|
matricula = "";
|
||||||
|
email = "";
|
||||||
|
} catch (error: any) {
|
||||||
|
notice = {
|
||||||
|
type: "error",
|
||||||
|
message: error.message || "Erro ao processar solicitação",
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
carregando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-8 max-w-2xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<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="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<h1 class="text-4xl font-bold text-primary">Esqueci Minha Senha</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-base-content/70 text-lg">
|
||||||
|
Solicite a recuperação da sua senha de acesso
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Breadcrumbs -->
|
||||||
|
<div class="text-sm breadcrumbs mb-6">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">Dashboard</a></li>
|
||||||
|
<li>Esqueci Minha Senha</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alertas -->
|
||||||
|
{#if notice}
|
||||||
|
<div class="alert {notice.type === 'success' ? 'alert-success' : notice.type === 'error' ? 'alert-error' : '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"
|
||||||
|
>
|
||||||
|
{#if notice.type === "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 notice.type === "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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
<span>{notice.message}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !solicitacaoEnviada}
|
||||||
|
<!-- Formulário -->
|
||||||
|
<div class="card bg-base-100 shadow-xl border border-base-300">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-info mb-6">
|
||||||
|
<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>
|
||||||
|
<p class="text-sm">
|
||||||
|
Informe sua matrícula e e-mail cadastrados. A equipe de TI receberá sua solicitação e entrará em contato para resetar sua senha.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit} class="space-y-6">
|
||||||
|
<!-- Matrícula -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="matricula">
|
||||||
|
<span class="label-text font-semibold">Matrícula</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="matricula"
|
||||||
|
type="text"
|
||||||
|
placeholder="Digite sua matrícula"
|
||||||
|
class="input input-bordered input-primary w-full"
|
||||||
|
bind:value={matricula}
|
||||||
|
required
|
||||||
|
disabled={carregando}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- E-mail -->
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="email">
|
||||||
|
<span class="label-text font-semibold">E-mail</span>
|
||||||
|
<span class="label-text-alt text-error">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="Digite seu e-mail cadastrado"
|
||||||
|
class="input input-bordered input-primary w-full"
|
||||||
|
bind:value={email}
|
||||||
|
required
|
||||||
|
disabled={carregando}
|
||||||
|
/>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text-alt text-base-content/60">
|
||||||
|
Use o e-mail cadastrado no sistema
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botões -->
|
||||||
|
<div class="flex gap-4 justify-end mt-8">
|
||||||
|
<a href="/" class="btn btn-ghost" class:btn-disabled={carregando}>
|
||||||
|
<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
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled={carregando}
|
||||||
|
>
|
||||||
|
{#if carregando}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
Enviando...
|
||||||
|
{: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="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>
|
||||||
|
Enviar Solicitação
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Mensagem de Sucesso -->
|
||||||
|
<div class="card bg-success/10 shadow-xl border border-success/30">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="flex justify-center mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 text-success" 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>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold text-success mb-4">Solicitação Enviada!</h2>
|
||||||
|
<p class="text-base-content/70 mb-6">
|
||||||
|
Sua solicitação de recuperação de senha foi enviada para a equipe de TI.
|
||||||
|
Você receberá um contato em breve com as instruções para resetar sua senha.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-4 justify-center">
|
||||||
|
<a href="/" 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="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 ao Dashboard
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-ghost" onclick={() => solicitacaoEnviada = false}>
|
||||||
|
Enviar Nova Solicitação
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Card de Contato -->
|
||||||
|
<div class="mt-6 card bg-base-200 shadow-lg">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-info" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<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>
|
||||||
|
Precisa de Ajuda?
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
Se você não conseguir recuperar sua senha ou tiver problemas com o sistema, entre em contato diretamente com a equipe de TI:
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 space-y-2">
|
||||||
|
<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="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>
|
||||||
|
<span class="text-sm">ti@sgse.pe.gov.br</span>
|
||||||
|
</div>
|
||||||
|
<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="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm">(81) 3183-8000</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
99
apps/web/src/routes/(dashboard)/financeiro/+page.svelte
Normal file
99
apps/web/src/routes/(dashboard)/financeiro/+page.svelte
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-4">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="text-sm breadcrumbs mb-4">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/" class="text-primary hover:underline">Dashboard</a></li>
|
||||||
|
<li>Financeiro</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cabeçalho -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-4 mb-2">
|
||||||
|
<div class="p-3 bg-green-500/20 rounded-xl">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-primary">Financeiro</h1>
|
||||||
|
<p class="text-base-content/70">Gestão financeira e orçamentária da secretaria</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card de Aviso -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div class="mb-6">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 text-base-content/20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
|
||||||
|
<p class="text-base-content/70 max-w-md mb-6">
|
||||||
|
O módulo Financeiro está sendo desenvolvido e em breve estará disponível com funcionalidades completas de gestão financeira e orçamentária.
|
||||||
|
</p>
|
||||||
|
<div class="badge badge-warning badge-lg gap-2">
|
||||||
|
<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 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
|
</svg>
|
||||||
|
Em Desenvolvimento
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Funcionalidades Previstas -->
|
||||||
|
<div class="mt-6">
|
||||||
|
<h3 class="text-xl font-bold mb-4">Funcionalidades Previstas</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<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="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h4 class="font-semibold">Controle Orçamentário</h4>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-base-content/70">Gestão e acompanhamento do orçamento anual</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<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="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h4 class="font-semibold">Fluxo de Caixa</h4>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-base-content/70">Controle de entradas e saídas financeiras</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<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="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>
|
||||||
|
<h4 class="font-semibold">Relatórios Financeiros</h4>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-base-content/70">Geração de relatórios e demonstrativos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
48
apps/web/src/routes/(dashboard)/gestao-pessoas/+page.svelte
Normal file
48
apps/web/src/routes/(dashboard)/gestao-pessoas/+page.svelte
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-4">
|
||||||
|
<div class="text-sm breadcrumbs mb-4">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/" class="text-primary hover:underline">Dashboard</a></li>
|
||||||
|
<li>Secretaria de Gestão de Pessoas</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-4 mb-2">
|
||||||
|
<div class="p-3 bg-teal-500/20 rounded-xl">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-teal-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-primary">Secretaria de Gestão de Pessoas</h1>
|
||||||
|
<p class="text-base-content/70">Gestão estratégica de pessoas</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div class="mb-6">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 text-base-content/20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
|
||||||
|
<p class="text-base-content/70 max-w-md mb-6">
|
||||||
|
O módulo da Secretaria de Gestão de Pessoas está sendo desenvolvido e em breve estará disponível com funcionalidades completas de gestão estratégica de pessoas.
|
||||||
|
</p>
|
||||||
|
<div class="badge badge-warning badge-lg gap-2">
|
||||||
|
<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 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
|
</svg>
|
||||||
|
Em Desenvolvimento
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
48
apps/web/src/routes/(dashboard)/juridico/+page.svelte
Normal file
48
apps/web/src/routes/(dashboard)/juridico/+page.svelte
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-4">
|
||||||
|
<div class="text-sm breadcrumbs mb-4">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/" class="text-primary hover:underline">Dashboard</a></li>
|
||||||
|
<li>Jurídico</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-4 mb-2">
|
||||||
|
<div class="p-3 bg-red-500/20 rounded-xl">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-primary">Jurídico</h1>
|
||||||
|
<p class="text-base-content/70">Assessoria jurídica e gestão de processos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div class="mb-6">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 text-base-content/20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
|
||||||
|
<p class="text-base-content/70 max-w-md mb-6">
|
||||||
|
O módulo Jurídico está sendo desenvolvido e em breve estará disponível com funcionalidades completas de assessoria jurídica e gestão de processos.
|
||||||
|
</p>
|
||||||
|
<div class="badge badge-warning badge-lg gap-2">
|
||||||
|
<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 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
|
</svg>
|
||||||
|
Em Desenvolvimento
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
99
apps/web/src/routes/(dashboard)/licitacoes/+page.svelte
Normal file
99
apps/web/src/routes/(dashboard)/licitacoes/+page.svelte
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-4">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="text-sm breadcrumbs mb-4">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/" class="text-primary hover:underline">Dashboard</a></li>
|
||||||
|
<li>Licitações</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cabeçalho -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-4 mb-2">
|
||||||
|
<div class="p-3 bg-orange-500/20 rounded-xl">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-orange-600" 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-primary">Licitações</h1>
|
||||||
|
<p class="text-base-content/70">Gestão de processos licitatórios</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card de Aviso -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div class="mb-6">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 text-base-content/20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
|
||||||
|
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
|
||||||
|
<p class="text-base-content/70 max-w-md mb-6">
|
||||||
|
O módulo de Licitações está sendo desenvolvido e em breve estará disponível com funcionalidades completas para gestão de processos licitatórios.
|
||||||
|
</p>
|
||||||
|
<div class="badge badge-warning badge-lg gap-2">
|
||||||
|
<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 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
|
</svg>
|
||||||
|
Em Desenvolvimento
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Funcionalidades Previstas -->
|
||||||
|
<div class="mt-6">
|
||||||
|
<h3 class="text-xl font-bold mb-4">Funcionalidades Previstas</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<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="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>
|
||||||
|
</div>
|
||||||
|
<h4 class="font-semibold">Processos Licitatórios</h4>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-base-content/70">Cadastro e acompanhamento de licitações</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<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="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h4 class="font-semibold">Fornecedores</h4>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-base-content/70">Cadastro e gestão de fornecedores</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<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="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>
|
||||||
|
<h4 class="font-semibold">Documentação</h4>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-base-content/70">Gestão de documentos e editais</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
174
apps/web/src/routes/(dashboard)/perfil/+page.svelte
Normal file
174
apps/web/src/routes/(dashboard)/perfil/+page.svelte
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { authStore } from "$lib/stores/auth.svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!authStore.autenticado) {
|
||||||
|
goto("/");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatarData(timestamp?: number): string {
|
||||||
|
if (!timestamp) return "Nunca";
|
||||||
|
return new Date(timestamp).toLocaleString("pt-BR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoleBadgeClass(nivel: number): string {
|
||||||
|
if (nivel === 0) return "badge-error";
|
||||||
|
if (nivel === 1) return "badge-warning";
|
||||||
|
if (nivel === 2) return "badge-info";
|
||||||
|
return "badge-success";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-8 max-w-4xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
<h1 class="text-4xl font-bold text-primary">Meu Perfil</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-base-content/70 text-lg">
|
||||||
|
Informações da sua conta no sistema
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Breadcrumbs -->
|
||||||
|
<div class="text-sm breadcrumbs mb-6">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">Dashboard</a></li>
|
||||||
|
<li>Perfil</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if authStore.usuario}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<!-- Card Principal -->
|
||||||
|
<div class="md:col-span-2 card bg-base-100 shadow-xl border border-base-300">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<div class="avatar placeholder">
|
||||||
|
<div class="bg-primary text-primary-content rounded-full w-24">
|
||||||
|
<span class="text-3xl">{authStore.usuario.nome.charAt(0)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold">{authStore.usuario.nome}</h2>
|
||||||
|
<p class="text-base-content/60">{authStore.usuario.email}</p>
|
||||||
|
<div class="mt-2">
|
||||||
|
<span class="badge {getRoleBadgeClass(authStore.usuario.role.nivel)} badge-lg">
|
||||||
|
{authStore.usuario.role.nome}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-base-content/60 mb-1">Matrícula</p>
|
||||||
|
<p class="font-semibold text-lg">
|
||||||
|
<code class="bg-base-200 px-3 py-1 rounded">{authStore.usuario.matricula}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-base-content/60 mb-1">Nível de Acesso</p>
|
||||||
|
<p class="font-semibold text-lg">Nível {authStore.usuario.role.nivel}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-base-content/60 mb-1">E-mail</p>
|
||||||
|
<p class="font-semibold">{authStore.usuario.email}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if authStore.usuario.role.setor}
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-base-content/60 mb-1">Setor</p>
|
||||||
|
<p class="font-semibold">{authStore.usuario.role.setor}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Ações Rápidas -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="card bg-base-100 shadow-xl border border-base-300">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg mb-4">Ações Rápidas</h3>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<a href="/alterar-senha" class="btn btn-primary btn-block justify-start">
|
||||||
|
<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 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>
|
||||||
|
Alterar Senha
|
||||||
|
</a>
|
||||||
|
<a href="/" class="btn btn-ghost btn-block justify-start">
|
||||||
|
<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 ao Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-info/10 shadow-xl border border-info/30">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-sm">
|
||||||
|
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
Informação
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
Para alterar outras informações do seu perfil, entre em contato com a equipe de TI.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Segurança -->
|
||||||
|
<div class="card bg-base-100 shadow-xl border border-base-300 mt-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3 class="card-title text-lg mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
Segurança da Conta
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="stat bg-base-200 rounded-lg">
|
||||||
|
<div class="stat-title">Status da Conta</div>
|
||||||
|
<div class="stat-value text-success text-2xl">Ativa</div>
|
||||||
|
<div class="stat-desc">Sua conta está ativa e segura</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-base-200 rounded-lg">
|
||||||
|
<div class="stat-title">Primeiro Acesso</div>
|
||||||
|
<div class="stat-value text-2xl">{authStore.usuario.primeiroAcesso ? "Sim" : "Não"}</div>
|
||||||
|
<div class="stat-desc">
|
||||||
|
{#if authStore.usuario.primeiroAcesso}
|
||||||
|
Altere sua senha após o primeiro login
|
||||||
|
{:else}
|
||||||
|
Senha já foi alterada
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-4">
|
||||||
|
<div class="text-sm breadcrumbs mb-4">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/" class="text-primary hover:underline">Dashboard</a></li>
|
||||||
|
<li>Programas Esportivos</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-4 mb-2">
|
||||||
|
<div class="p-3 bg-yellow-500/20 rounded-xl">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-primary">Programas Esportivos</h1>
|
||||||
|
<p class="text-base-content/70">Gestão de programas e projetos esportivos</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div class="mb-6">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 text-base-content/20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
|
||||||
|
<p class="text-base-content/70 max-w-md mb-6">
|
||||||
|
O módulo de Programas Esportivos está sendo desenvolvido e em breve estará disponível com funcionalidades completas para gestão de programas e projetos esportivos.
|
||||||
|
</p>
|
||||||
|
<div class="badge badge-warning badge-lg gap-2">
|
||||||
|
<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 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
|
</svg>
|
||||||
|
Em Desenvolvimento
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
@@ -1,39 +1,253 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { resolve } from "$app/paths";
|
import { goto } from "$app/navigation";
|
||||||
|
import { useQuery } from "convex-svelte";
|
||||||
|
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||||
|
|
||||||
|
// Buscar estatísticas para exibir nos cards
|
||||||
|
const statsQuery = useQuery(api.dashboard.getStats, {});
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
categoria: "Gestão de Funcionários",
|
||||||
|
descricao: "Gerencie o cadastro e informações dos funcionários",
|
||||||
|
icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>`,
|
||||||
|
gradient: "from-blue-500/10 to-blue-600/20",
|
||||||
|
accentColor: "text-blue-600",
|
||||||
|
bgIcon: "bg-blue-500/20",
|
||||||
|
opcoes: [
|
||||||
|
{
|
||||||
|
nome: "Cadastrar Funcionário",
|
||||||
|
descricao: "Adicionar novo funcionário ao sistema",
|
||||||
|
href: "/recursos-humanos/funcionarios/cadastro",
|
||||||
|
icon: `<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="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>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nome: "Listar Funcionários",
|
||||||
|
descricao: "Visualizar e editar cadastros",
|
||||||
|
href: "/recursos-humanos/funcionarios",
|
||||||
|
icon: `<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 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 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||||
|
</svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nome: "Excluir Cadastro",
|
||||||
|
descricao: "Remover funcionário do sistema",
|
||||||
|
href: "/recursos-humanos/funcionarios/excluir",
|
||||||
|
icon: `<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>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nome: "Relatórios",
|
||||||
|
descricao: "Visualizar estatísticas e gráficos",
|
||||||
|
href: "/recursos-humanos/funcionarios/relatorios",
|
||||||
|
icon: `<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 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
categoria: "Gestão de Símbolos",
|
||||||
|
descricao: "Gerencie cargos comissionados e funções gratificadas",
|
||||||
|
icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
|
||||||
|
</svg>`,
|
||||||
|
gradient: "from-green-500/10 to-green-600/20",
|
||||||
|
accentColor: "text-green-600",
|
||||||
|
bgIcon: "bg-green-500/20",
|
||||||
|
opcoes: [
|
||||||
|
{
|
||||||
|
nome: "Cadastrar Símbolo",
|
||||||
|
descricao: "Adicionar novo cargo ou função",
|
||||||
|
href: "/recursos-humanos/simbolos/cadastro",
|
||||||
|
icon: `<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 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nome: "Listar Símbolos",
|
||||||
|
descricao: "Visualizar e editar símbolos",
|
||||||
|
href: "/recursos-humanos/simbolos",
|
||||||
|
icon: `<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 6h16M4 10h16M4 14h16M4 18h16" />
|
||||||
|
</svg>`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<main class="container mx-auto px-4 py-4">
|
||||||
<h2 class="text-3xl font-bold text-brand-dark">Recursos Humanos</h2>
|
<!-- Cabeçalho -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-4xl font-bold text-primary mb-2">Recursos Humanos</h1>
|
||||||
|
<p class="text-lg text-base-content/70">
|
||||||
|
Gerencie funcionários, símbolos e visualize relatórios do departamento
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<!-- Estatísticas Rápidas -->
|
||||||
<h3 class="text-lg font-bold text-brand-dark col-span-4">Funcionários</h3>
|
{#if statsQuery.data}
|
||||||
<a
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||||
href={resolve("/recursos-humanos/funcionarios/cadastro")}
|
<div class="stats shadow-lg bg-gradient-to-br from-primary/10 to-primary/20">
|
||||||
class="p-4 rounded-xl border hover:shadow bgbase-100"
|
<div class="stat">
|
||||||
>Cadastrar Funcionários</a
|
<div class="stat-figure text-primary">
|
||||||
>
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<a
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
href={resolve("/recursos-humanos/funcionarios")}
|
</svg>
|
||||||
class="p-4 rounded-xl border hover:shadow bgbase-100">Editar Cadastro</a
|
</div>
|
||||||
>
|
<div class="stat-title">Total</div>
|
||||||
<a
|
<div class="stat-value text-primary">{statsQuery.data.totalFuncionarios}</div>
|
||||||
href={resolve("/recursos-humanos/funcionarios/excluir")}
|
<div class="stat-desc">Funcionários cadastrados</div>
|
||||||
class="p-4 rounded-xl border hover:shadow bgbase-100">Excluir Cadastro</a
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={resolve("/recursos-humanos/funcionarios/relatorios")}
|
|
||||||
class="p-4 rounded-xl border hover:shadow bgbase-100">Relatórios</a
|
|
||||||
>
|
|
||||||
|
|
||||||
<h3 class="text-lg font-bold text-brand-dark col-span-4">Simbolos</h3>
|
|
||||||
<a
|
|
||||||
href={resolve("/recursos-humanos/simbolos/cadastro")}
|
|
||||||
class="p-4 rounded-xl border hover:shadow bgbase-100"
|
|
||||||
>Cadastrar Simbolos</a
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={resolve("/recursos-humanos/simbolos")}
|
|
||||||
class="p-4 rounded-xl border hover:shadow bgbase-100">Listar Simbolos</a
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="stats shadow-lg bg-gradient-to-br from-success/10 to-success/20">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-figure text-success">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" 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>
|
||||||
|
</div>
|
||||||
|
<div class="stat-title">Ativos</div>
|
||||||
|
<div class="stat-value text-success">{statsQuery.data.funcionariosAtivos}</div>
|
||||||
|
<div class="stat-desc">Funcionários ativos</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats shadow-lg bg-gradient-to-br from-secondary/10 to-secondary/20">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-figure text-secondary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-title">Símbolos</div>
|
||||||
|
<div class="stat-value text-secondary">{statsQuery.data.totalSimbolos}</div>
|
||||||
|
<div class="stat-desc">Cargos e funções</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats shadow-lg bg-gradient-to-br from-accent/10 to-accent/20">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-figure text-accent">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="stat-title">CC / FG</div>
|
||||||
|
<div class="stat-value text-accent">{statsQuery.data.cargoComissionado} / {statsQuery.data.funcaoGratificada}</div>
|
||||||
|
<div class="stat-desc">Distribuição</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Menu de Opções -->
|
||||||
|
<div class="space-y-8">
|
||||||
|
{#each menuItems as categoria}
|
||||||
|
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300">
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Cabeçalho da Categoria -->
|
||||||
|
<div class="flex items-start gap-6 mb-6">
|
||||||
|
<div class="p-4 {categoria.bgIcon} rounded-2xl">
|
||||||
|
<div class="{categoria.accentColor}">
|
||||||
|
{@html categoria.icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h2 class="card-title text-2xl mb-2 {categoria.accentColor}">
|
||||||
|
{categoria.categoria}
|
||||||
|
</h2>
|
||||||
|
<p class="text-base-content/70">{categoria.descricao}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid de Opções -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{#each categoria.opcoes as opcao}
|
||||||
|
<a
|
||||||
|
href={opcao.href}
|
||||||
|
class="group relative overflow-hidden rounded-xl border-2 border-base-300 bg-gradient-to-br {categoria.gradient} p-6 hover:border-primary hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<div class="flex items-start justify-between mb-4">
|
||||||
|
<div class="p-3 bg-base-100 rounded-lg group-hover:bg-primary group-hover:text-white transition-colors duration-300">
|
||||||
|
<div class="{categoria.accentColor} group-hover:text-white">
|
||||||
|
{@html opcao.icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5 text-base-content/30 group-hover:text-primary transition-colors duration-300"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold text-base-content mb-2 group-hover:text-primary transition-colors duration-300">
|
||||||
|
{opcao.nome}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-base-content/70 flex-1">
|
||||||
|
{opcao.descricao}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card de Ajuda -->
|
||||||
|
<div class="alert alert-info shadow-lg mt-8">
|
||||||
|
<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">Precisa de ajuda?</h3>
|
||||||
|
<div class="text-sm">
|
||||||
|
Entre em contato com o suporte técnico ou consulte a documentação do sistema para mais informações sobre as funcionalidades de Recursos Humanos.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
animation: fadeInUp 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
animation: fadeInUp 0.6s ease-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -70,48 +70,132 @@
|
|||||||
$: needsScroll = filtered.length > 8;
|
$: needsScroll = filtered.length > 8;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6 pb-32">
|
<main class="container mx-auto px-4 py-4">
|
||||||
<div class="flex items-center justify-between">
|
<!-- Breadcrumb -->
|
||||||
<h2 class="text-3xl font-bold text-brand-dark">Funcionários</h2>
|
<div class="text-sm breadcrumbs mb-4">
|
||||||
<div class="space-x-2 flex items-center">
|
<ul>
|
||||||
<button class="btn btn-primary" onclick={navCadastro}>Novo Funcionário</button>
|
<li><a href="/recursos-humanos" class="text-primary hover:underline">Recursos Humanos</a></li>
|
||||||
|
<li>Funcionários</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cabeçalho -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="p-3 bg-blue-500/20 rounded-xl">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-primary">Funcionários Cadastrados</h1>
|
||||||
|
<p class="text-base-content/70">Gerencie os funcionários da secretaria</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-lg gap-2" onclick={navCadastro}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Novo Funcionário
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid md:grid-cols-4 gap-3 items-end">
|
<!-- Filtros -->
|
||||||
<div class="form-control">
|
<div class="card bg-base-100 shadow-xl mb-6">
|
||||||
<label class="label" for="func_nome"><span class="label-text">Nome</span></label>
|
<div class="card-body">
|
||||||
<input id="func_nome" class="input input-bordered" bind:value={filtroNome} oninput={applyFilters} />
|
<h2 class="card-title text-lg mb-4">
|
||||||
|
<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 de Pesquisa
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="func_nome">
|
||||||
|
<span class="label-text font-semibold">Nome</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="func_nome"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
placeholder="Buscar por nome..."
|
||||||
|
bind:value={filtroNome}
|
||||||
|
oninput={applyFilters}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control w-full">
|
||||||
<label class="label" for="func_cpf"><span class="label-text">CPF</span></label>
|
<label class="label" for="func_cpf">
|
||||||
<input id="func_cpf" class="input input-bordered" bind:value={filtroCPF} oninput={applyFilters} />
|
<span class="label-text font-semibold">CPF</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="func_cpf"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
placeholder="000.000.000-00"
|
||||||
|
bind:value={filtroCPF}
|
||||||
|
oninput={applyFilters}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control w-full">
|
||||||
<label class="label" for="func_matricula"><span class="label-text">Matrícula</span></label>
|
<label class="label" for="func_matricula">
|
||||||
<input id="func_matricula" class="input input-bordered" bind:value={filtroMatricula} oninput={applyFilters} />
|
<span class="label-text font-semibold">Matrícula</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="func_matricula"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
placeholder="Buscar por matrícula..."
|
||||||
|
bind:value={filtroMatricula}
|
||||||
|
oninput={applyFilters}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control w-full">
|
||||||
<label class="label" for="func_tipo"><span class="label-text">Símbolo Tipo</span></label>
|
<label class="label" for="func_tipo">
|
||||||
<select id="func_tipo" class="select select-bordered" bind:value={filtroTipo} oninput={applyFilters}>
|
<span class="label-text font-semibold">Símbolo Tipo</span>
|
||||||
<option value="">Todos</option>
|
</label>
|
||||||
<option value="cargo_comissionado">Cargo comissionado</option>
|
<select id="func_tipo" class="select select-bordered focus:select-primary w-full" bind:value={filtroTipo} oninput={applyFilters}>
|
||||||
<option value="funcao_gratificada">Função gratificada</option>
|
<option value="">Todos os tipos</option>
|
||||||
|
<option value="cargo_comissionado">Cargo Comissionado</option>
|
||||||
|
<option value="funcao_gratificada">Função Gratificada</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{#if filtroNome || filtroCPF || filtroMatricula || filtroTipo}
|
||||||
|
<div class="mt-4">
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm gap-2"
|
||||||
|
onclick={() => {
|
||||||
|
filtroNome = "";
|
||||||
|
filtroCPF = "";
|
||||||
|
filtroMatricula = "";
|
||||||
|
filtroTipo = "";
|
||||||
|
applyFilters();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
Limpar Filtros
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="overflow-x-auto bg-base-100 rounded-lg shadow-sm mb-2" class:overflow-y-auto={needsScroll} style={needsScroll ? "max-height: calc(100vh - 180px);" : "overflow-y: visible;"}>
|
<!-- Tabela de Funcionários -->
|
||||||
<table class="table table-zebra">
|
<div class="card bg-base-100 shadow-xl">
|
||||||
<thead>
|
<div class="card-body p-0">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div class="overflow-y-auto" style="max-height: {filtered.length > 8 ? '600px' : 'none'};">
|
||||||
|
<table class="table table-zebra w-full">
|
||||||
|
<thead class="sticky top-0 bg-base-200 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Nome</th>
|
<th class="font-bold">Nome</th>
|
||||||
<th>CPF</th>
|
<th class="font-bold">CPF</th>
|
||||||
<th>Matrícula</th>
|
<th class="font-bold">Matrícula</th>
|
||||||
<th>Tipo</th>
|
<th class="font-bold">Tipo</th>
|
||||||
<th>Cidade</th>
|
<th class="font-bold">Cidade</th>
|
||||||
<th>UF</th>
|
<th class="font-bold">UF</th>
|
||||||
<th class="text-right">Ações</th>
|
<th class="text-right font-bold">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -139,7 +223,16 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Informação sobre resultados -->
|
||||||
|
<div class="mt-4 text-sm text-base-content/70 text-center">
|
||||||
|
Exibindo {filtered.length} de {list.length} funcionário(s)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal de Confirmação de Exclusão -->
|
||||||
<dialog id="delete_modal_func" class="modal">
|
<dialog id="delete_modal_func" class="modal">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 class="font-bold text-lg mb-4">Confirmar Exclusão</h3>
|
<h3 class="font-bold text-lg mb-4">Confirmar Exclusão</h3>
|
||||||
@@ -168,5 +261,5 @@
|
|||||||
<button>close</button>
|
<button>close</button>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -190,19 +190,59 @@
|
|||||||
load();
|
load();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form class="max-w-3xl mx-auto p-4" onsubmit={(e) => { e.preventDefault(); e.stopPropagation(); form.handleSubmit(); }}>
|
<main class="container mx-auto px-4 py-4 max-w-5xl">
|
||||||
<div class="card bg-base-100 shadow-xl">
|
<!-- Breadcrumb -->
|
||||||
<div class="card-body space-y-6">
|
<div class="text-sm breadcrumbs mb-4">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/recursos-humanos" class="text-primary hover:underline">Recursos Humanos</a></li>
|
||||||
|
<li><a href="/recursos-humanos/funcionarios" class="text-primary hover:underline">Funcionários</a></li>
|
||||||
|
<li>Editar</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cabeçalho -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-4 mb-2">
|
||||||
|
<div class="p-3 bg-yellow-500/20 rounded-xl">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-yellow-600" 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>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-primary">Editar Funcionário</h1>
|
||||||
|
<p class="text-base-content/70">Atualize os dados do funcionário</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alertas -->
|
||||||
{#if notice}
|
{#if notice}
|
||||||
<div class="alert" class:alert-success={notice.kind === "success"} class:alert-error={notice.kind === "error"}>
|
<div class="alert mb-6 shadow-lg" class:alert-success={notice.kind === "success"} class:alert-error={notice.kind === "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 notice.kind === "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>{notice.text}</span>
|
<span>{notice.text}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div>
|
<!-- Formulário -->
|
||||||
<h2 class="card-title text-3xl">Editar Funcionários</h2>
|
<form
|
||||||
<p class="opacity-70">Atualize os dados do funcionário.</p>
|
class="space-y-6"
|
||||||
</div>
|
onsubmit={(e) => { e.preventDefault(); e.stopPropagation(); form.handleSubmit(); }}
|
||||||
|
>
|
||||||
|
<!-- Card: Informações Pessoais -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<h2 class="card-title text-xl border-b pb-3">
|
||||||
|
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
Informações Pessoais
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<form.Field name="nome" validators={{ onChange: schema.shape.nome }}>
|
<form.Field name="nome" validators={{ onChange: schema.shape.nome }}>
|
||||||
@@ -222,6 +262,19 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Endereço -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<h2 class="card-title text-xl border-b pb-3">
|
||||||
|
<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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
Endereço
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<form.Field name="cep" validators={{ onChange: schema.shape.cep }}>
|
<form.Field name="cep" validators={{ onChange: schema.shape.cep }}>
|
||||||
@@ -258,6 +311,18 @@
|
|||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Documentos -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<h2 class="card-title text-xl border-b pb-3">
|
||||||
|
<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 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2" />
|
||||||
|
</svg>
|
||||||
|
Documentos
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<form.Field name="cpf" validators={{ onChange: schema.shape.cpf }}>
|
<form.Field name="cpf" validators={{ onChange: schema.shape.cpf }}>
|
||||||
@@ -277,6 +342,18 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Datas -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<h2 class="card-title text-xl border-b pb-3">
|
||||||
|
<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 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
Datas
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<form.Field name="nascimento" validators={{ onChange: schema.shape.nascimento }}>
|
<form.Field name="nascimento" validators={{ onChange: schema.shape.nascimento }}>
|
||||||
@@ -296,6 +373,18 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Contato -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<h2 class="card-title text-xl border-b pb-3">
|
||||||
|
<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 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>
|
||||||
|
Contato
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<form.Field name="email" validators={{ onChange: schema.shape.email }}>
|
<form.Field name="email" validators={{ onChange: schema.shape.email }}>
|
||||||
@@ -315,7 +404,18 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Cargo/Função -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<h2 class="card-title text-xl border-b pb-3">
|
||||||
|
<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="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
Cargo/Função
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<form.Field name="simboloTipo" validators={{ onChange: schema.shape.simboloTipo }}>
|
<form.Field name="simboloTipo" validators={{ onChange: schema.shape.simboloTipo }}>
|
||||||
@@ -344,15 +444,40 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Ações -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
<form.Subscribe selector={(s) => ({ canSubmit: s.canSubmit, isSubmitting: s.isSubmitting })}>
|
<form.Subscribe selector={(s) => ({ canSubmit: s.canSubmit, isSubmitting: s.isSubmitting })}>
|
||||||
{#snippet children({ canSubmit, isSubmitting })}
|
{#snippet children({ canSubmit, isSubmitting })}
|
||||||
<div class="card-actions justify-end pt-2">
|
<div class="flex flex-col sm:flex-row gap-3 justify-end">
|
||||||
<button type="button" class="btn btn-ghost" disabled={isSubmitting} onclick={() => goto("/recursos-humanos/funcionarios")}>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-lg"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onclick={() => goto("/recursos-humanos/funcionarios")}
|
||||||
|
>
|
||||||
|
<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
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary" class:loading={isSubmitting} disabled={isSubmitting || !canSubmit}>
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary btn-lg"
|
||||||
|
disabled={isSubmitting || !canSubmit}
|
||||||
|
>
|
||||||
|
{#if isSubmitting}
|
||||||
|
<span class="loading loading-spinner"></span>
|
||||||
|
Salvando...
|
||||||
|
{: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>
|
||||||
Salvar Alterações
|
Salvar Alterações
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -360,5 +485,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -233,22 +233,59 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form
|
<main class="container mx-auto px-4 py-4 max-w-5xl">
|
||||||
class="max-w-3xl mx-auto p-4"
|
<!-- Breadcrumb -->
|
||||||
onsubmit={(e) => { e.preventDefault(); e.stopPropagation(); form.handleSubmit(); }}
|
<div class="text-sm breadcrumbs mb-4">
|
||||||
>
|
<ul>
|
||||||
<div class="card bg-base-100 shadow-xl">
|
<li><a href="/recursos-humanos" class="text-primary hover:underline">Recursos Humanos</a></li>
|
||||||
<div class="card-body space-y-6">
|
<li><a href="/recursos-humanos/funcionarios" class="text-primary hover:underline">Funcionários</a></li>
|
||||||
|
<li>Cadastrar</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cabeçalho -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-4 mb-2">
|
||||||
|
<div class="p-3 bg-blue-500/20 rounded-xl">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-blue-600" 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-primary">Cadastro de Funcionário</h1>
|
||||||
|
<p class="text-base-content/70">Preencha os campos abaixo para cadastrar um novo funcionário</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alertas -->
|
||||||
{#if notice}
|
{#if notice}
|
||||||
<div class="alert" class:alert-success={notice.kind === "success"} class:alert-error={notice.kind === "error"}>
|
<div class="alert mb-6 shadow-lg" class:alert-success={notice.kind === "success"} class:alert-error={notice.kind === "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 notice.kind === "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>{notice.text}</span>
|
<span>{notice.text}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div>
|
<!-- Formulário -->
|
||||||
<h2 class="card-title text-3xl">Cadastro de Funcionários</h2>
|
<form
|
||||||
<p class="opacity-70">Preencha os campos abaixo para cadastrar um novo funcionário.</p>
|
class="space-y-6"
|
||||||
</div>
|
onsubmit={(e) => { e.preventDefault(); e.stopPropagation(); form.handleSubmit(); }}
|
||||||
|
>
|
||||||
|
<!-- Card: Informações Pessoais -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<h2 class="card-title text-xl border-b pb-3">
|
||||||
|
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
Informações Pessoais
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<form.Field name="nome" validators={{ onChange: schema.shape.nome }}>
|
<form.Field name="nome" validators={{ onChange: schema.shape.nome }}>
|
||||||
@@ -268,6 +305,19 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Endereço -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<h2 class="card-title text-xl border-b pb-3">
|
||||||
|
<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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
Endereço
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<form.Field name="cep" validators={{ onChange: schema.shape.cep }}>
|
<form.Field name="cep" validators={{ onChange: schema.shape.cep }}>
|
||||||
@@ -304,6 +354,18 @@
|
|||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Documentos -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<h2 class="card-title text-xl border-b pb-3">
|
||||||
|
<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 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2" />
|
||||||
|
</svg>
|
||||||
|
Documentos
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<form.Field name="cpf" validators={{ onChange: schema.shape.cpf }}>
|
<form.Field name="cpf" validators={{ onChange: schema.shape.cpf }}>
|
||||||
@@ -323,6 +385,18 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Datas -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<h2 class="card-title text-xl border-b pb-3">
|
||||||
|
<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 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
Datas
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<form.Field name="nascimento" validators={{ onChange: schema.shape.nascimento }}>
|
<form.Field name="nascimento" validators={{ onChange: schema.shape.nascimento }}>
|
||||||
@@ -342,6 +416,18 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Contato -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<h2 class="card-title text-xl border-b pb-3">
|
||||||
|
<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 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>
|
||||||
|
Contato
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<form.Field name="email" validators={{ onChange: schema.shape.email }}>
|
<form.Field name="email" validators={{ onChange: schema.shape.email }}>
|
||||||
@@ -361,7 +447,18 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Cargo/Função -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<h2 class="card-title text-xl border-b pb-3">
|
||||||
|
<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="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
Cargo/Função
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<form.Field name="simboloTipo" validators={{ onChange: schema.shape.simboloTipo }}>
|
<form.Field name="simboloTipo" validators={{ onChange: schema.shape.simboloTipo }}>
|
||||||
@@ -390,15 +487,40 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card: Ações -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
<form.Subscribe selector={(s) => ({ canSubmit: s.canSubmit, isSubmitting: s.isSubmitting })}>
|
<form.Subscribe selector={(s) => ({ canSubmit: s.canSubmit, isSubmitting: s.isSubmitting })}>
|
||||||
{#snippet children({ canSubmit, isSubmitting })}
|
{#snippet children({ canSubmit, isSubmitting })}
|
||||||
<div class="card-actions justify-end pt-2">
|
<div class="flex flex-col sm:flex-row gap-3 justify-end">
|
||||||
<button type="button" class="btn btn-ghost" disabled={isSubmitting} onclick={() => goto("/recursos-humanos/funcionarios")}>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-lg"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
onclick={() => goto("/recursos-humanos/funcionarios")}
|
||||||
|
>
|
||||||
|
<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
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary" class:loading={isSubmitting} disabled={isSubmitting || !canSubmit}>
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary btn-lg"
|
||||||
|
disabled={isSubmitting || !canSubmit}
|
||||||
|
>
|
||||||
|
{#if isSubmitting}
|
||||||
|
<span class="loading loading-spinner"></span>
|
||||||
|
Cadastrando...
|
||||||
|
{: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>
|
||||||
Cadastrar Funcionário
|
Cadastrar Funcionário
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -406,5 +528,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
let list: Array<any> = [];
|
let list: Array<any> = $state([]);
|
||||||
let filtro = "";
|
let filtro = $state("");
|
||||||
let notice: { kind: "success" | "error"; text: string } | null = null;
|
let notice: { kind: "success" | "error"; text: string } | null = $state(null);
|
||||||
let toDelete: { id: string; nome: string } | null = null;
|
let toDelete: { id: string; nome: string; cpf: string; matricula: string } | null = $state(null);
|
||||||
let deletingId: string | null = null;
|
let deletingId: string | null = $state(null);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
@@ -20,8 +20,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDeleteModal(id: string, nome: string) {
|
function openDeleteModal(id: string, nome: string, cpf: string, matricula: string) {
|
||||||
toDelete = { id, nome };
|
toDelete = { id, nome, cpf, matricula };
|
||||||
(document.getElementById("delete_modal_func_excluir") as HTMLDialogElement)?.showModal();
|
(document.getElementById("delete_modal_func_excluir") as HTMLDialogElement)?.showModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,92 +36,295 @@
|
|||||||
deletingId = toDelete.id;
|
deletingId = toDelete.id;
|
||||||
await client.mutation(api.funcionarios.remove, { id: toDelete.id } as any);
|
await client.mutation(api.funcionarios.remove, { id: toDelete.id } as any);
|
||||||
closeDeleteModal();
|
closeDeleteModal();
|
||||||
notice = { kind: "success", text: "Funcionário excluído com sucesso." };
|
notice = { kind: "success", text: `Funcionário "${toDelete.nome}" excluído com sucesso!` };
|
||||||
await load();
|
await load();
|
||||||
|
|
||||||
|
// Auto-fechar mensagem de sucesso após 5 segundos
|
||||||
|
setTimeout(() => {
|
||||||
|
notice = null;
|
||||||
|
}, 5000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notice = { kind: "error", text: "Erro ao excluir cadastro." };
|
notice = { kind: "error", text: "Erro ao excluir cadastro. Tente novamente." };
|
||||||
} finally {
|
} finally {
|
||||||
deletingId = null;
|
deletingId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function back() { goto("/recursos-humanos/funcionarios"); }
|
function limparFiltro() {
|
||||||
|
filtro = "";
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => { void load(); });
|
function back() {
|
||||||
|
goto("/recursos-humanos/funcionarios");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Computed para lista filtrada
|
||||||
|
const filtered = $derived(
|
||||||
|
list.filter((f) => {
|
||||||
|
const q = (filtro || "").toLowerCase();
|
||||||
|
return !q ||
|
||||||
|
(f.nome || "").toLowerCase().includes(q) ||
|
||||||
|
(f.cpf || "").includes(q) ||
|
||||||
|
(f.matricula || "").toLowerCase().includes(q);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void load();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<!-- Breadcrumb -->
|
||||||
|
<div class="text-sm breadcrumbs mb-4">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="/" class="text-primary hover:text-primary-focus">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||||
|
</svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/recursos-humanos" class="text-primary hover:text-primary-focus">Recursos Humanos</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/recursos-humanos/funcionarios" class="text-primary hover:text-primary-focus">Funcionários</a>
|
||||||
|
</li>
|
||||||
|
<li class="font-semibold">Excluir Funcionários</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header com ícone e descrição -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<div class="p-3 bg-error/10 rounded-xl">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-error" 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>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h1 class="text-3xl font-bold text-base-content">Excluir Funcionários</h1>
|
||||||
|
<p class="text-base-content/60 mt-1">Selecione o funcionário que deseja remover do sistema</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost gap-2"
|
||||||
|
onclick={back}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
Voltar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alerta de sucesso/erro -->
|
||||||
{#if notice}
|
{#if notice}
|
||||||
<div class="alert" class:alert-success={notice.kind === "success"} class:alert-error={notice.kind === "error"}>
|
<div class="alert mb-6 shadow-lg" class:alert-success={notice.kind === "success"} class:alert-error={notice.kind === "error"}>
|
||||||
<span>{notice.text}</span>
|
{#if notice.kind === "success"}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<span class="font-semibold">{notice.text}</span>
|
||||||
|
<button class="btn btn-sm btn-ghost" onclick={() => notice = null} aria-label="Fechar alerta">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<!-- Card de Filtros -->
|
||||||
<h2 class="text-2xl font-bold">Excluir Funcionários</h2>
|
<div class="card bg-base-100 shadow-xl mb-6">
|
||||||
<button class="btn" onclick={back}>Voltar</button>
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="card-title text-lg">
|
||||||
|
<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="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 de Busca
|
||||||
|
</h2>
|
||||||
|
{#if filtro}
|
||||||
|
<button class="btn btn-sm btn-ghost gap-2" onclick={limparFiltro}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
Limpar Filtros
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control max-w-sm">
|
<div class="form-control w-full">
|
||||||
<label class="label" for="func_excluir_busca"><span class="label-text">Buscar por nome/CPF/matrícula</span></label>
|
<label class="label" for="func_excluir_busca">
|
||||||
<input id="func_excluir_busca" class="input input-bordered" bind:value={filtro} />
|
<span class="label-text font-semibold">Buscar por Nome, CPF ou Matrícula</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="func_excluir_busca"
|
||||||
|
type="text"
|
||||||
|
placeholder="Digite para filtrar..."
|
||||||
|
class="input input-bordered input-primary w-full focus:input-primary"
|
||||||
|
bind:value={filtro}
|
||||||
|
/>
|
||||||
|
<div class="label">
|
||||||
|
<span class="label-text-alt text-base-content/60">
|
||||||
|
{filtered.length} funcionário{filtered.length !== 1 ? 's' : ''} encontrado{filtered.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabela de Funcionários -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-lg mb-4">
|
||||||
|
<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 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>
|
||||||
|
Lista de Funcionários
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{#if list.length === 0}
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div class="p-4 bg-base-200 rounded-full mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-base-content/30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-lg font-semibold text-base-content/70">Nenhum funcionário cadastrado</p>
|
||||||
|
<p class="text-sm text-base-content/50 mt-2">Cadastre funcionários para gerenciá-los aqui</p>
|
||||||
|
</div>
|
||||||
|
{:else if filtered.length === 0}
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div class="p-4 bg-base-200 rounded-full mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 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>
|
||||||
|
</div>
|
||||||
|
<p class="text-lg font-semibold text-base-content/70">Nenhum resultado encontrado</p>
|
||||||
|
<p class="text-sm text-base-content/50 mt-2">Tente ajustar os filtros de busca</p>
|
||||||
|
<button class="btn btn-primary btn-sm mt-4" onclick={limparFiltro}>Limpar Filtros</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="table">
|
<table class="table table-zebra">
|
||||||
<thead>
|
<thead class="bg-base-200 sticky top-0 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Nome</th>
|
<th class="text-base">Nome</th>
|
||||||
<th>CPF</th>
|
<th class="text-base">CPF</th>
|
||||||
<th>Matrícula</th>
|
<th class="text-base">Matrícula</th>
|
||||||
<th>Ações</th>
|
<th class="text-base text-center">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each list.filter((f) => {
|
{#each filtered as f}
|
||||||
const q = (filtro || "").toLowerCase();
|
<tr class="hover">
|
||||||
return !q || (f.nome || "").toLowerCase().includes(q) || (f.cpf || "").includes(q) || (f.matricula || "").toLowerCase().includes(q);
|
<td class="font-semibold">{f.nome}</td>
|
||||||
}) as f}
|
|
||||||
<tr>
|
|
||||||
<td>{f.nome}</td>
|
|
||||||
<td>{f.cpf}</td>
|
<td>{f.cpf}</td>
|
||||||
<td>{f.matricula}</td>
|
<td><span class="badge badge-ghost">{f.matricula}</span></td>
|
||||||
<td>
|
<td class="text-center">
|
||||||
<button class="btn btn-error btn-sm" onclick={() => openDeleteModal(f._id, f.nome)}>Excluir</button>
|
<button
|
||||||
|
class="btn btn-error btn-sm gap-2"
|
||||||
|
onclick={() => openDeleteModal(f._id, f.nome, f.cpf, f.matricula)}
|
||||||
|
>
|
||||||
|
<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="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>
|
||||||
|
Excluir
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<dialog id="delete_modal_func_excluir" class="modal">
|
|
||||||
<div class="modal-box">
|
|
||||||
<h3 class="font-bold text-lg mb-4">Confirmar Exclusão</h3>
|
|
||||||
<div class="alert alert-warning mb-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>
|
|
||||||
<span>Esta ação não pode ser desfeita!</span>
|
|
||||||
</div>
|
|
||||||
{#if toDelete}
|
|
||||||
<p class="py-2">Tem certeza que deseja excluir o funcionário <strong class="text-error">{toDelete.nome}</strong>?</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
<div class="modal-action">
|
</div>
|
||||||
<form method="dialog" class="flex gap-2">
|
</div>
|
||||||
<button class="btn btn-ghost" onclick={closeDeleteModal} type="button">Cancelar</button>
|
|
||||||
<button class="btn btn-error" onclick={confirmDelete} disabled={deletingId !== null} type="button">
|
<!-- Modal de Confirmação de Exclusão -->
|
||||||
|
<dialog id="delete_modal_func_excluir" class="modal">
|
||||||
|
<div class="modal-box max-w-md">
|
||||||
|
<h3 class="font-bold text-2xl mb-4 flex items-center gap-2 text-error">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7" 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>
|
||||||
|
|
||||||
|
<div class="alert alert-warning mb-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>
|
||||||
|
<span class="font-bold">Atenção!</span>
|
||||||
|
<p class="text-sm">Esta ação não pode ser desfeita!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if toDelete}
|
||||||
|
<div class="bg-base-200 rounded-lg p-4 mb-4">
|
||||||
|
<p class="text-sm text-base-content/70 mb-3">Você está prestes a excluir o seguinte funcionário:</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
<strong class="text-error text-lg">{toDelete.nome}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="text-base-content/60">CPF:</span>
|
||||||
|
<span class="font-semibold">{toDelete.cpf}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="text-base-content/60">Matrícula:</span>
|
||||||
|
<span class="badge badge-ghost">{toDelete.matricula}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-center text-sm text-base-content/70 mb-6">
|
||||||
|
Tem certeza que deseja continuar?
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="modal-action justify-between">
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost gap-2"
|
||||||
|
onclick={closeDeleteModal}
|
||||||
|
disabled={deletingId !== null}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-error gap-2"
|
||||||
|
onclick={confirmDelete}
|
||||||
|
disabled={deletingId !== null}
|
||||||
|
>
|
||||||
{#if deletingId}
|
{#if deletingId}
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
Excluindo...
|
Excluindo...
|
||||||
{:else}
|
{: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="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>
|
||||||
Confirmar Exclusão
|
Confirmar Exclusão
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="dialog" class="modal-backdrop">
|
<form method="dialog" class="modal-backdrop">
|
||||||
<button>close</button>
|
<button>close</button>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,130 +29,248 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let chartWidth = 800;
|
let chartWidth = 900;
|
||||||
let chartHeight = 320;
|
let chartHeight = 400;
|
||||||
const padding = { top: 20, right: 20, bottom: 80, left: 70 };
|
const padding = { top: 40, right: 30, bottom: 100, left: 80 };
|
||||||
|
|
||||||
function getMax<T>(arr: Array<T>, sel: (t: T) => number): number {
|
function getMax<T>(arr: Array<T>, sel: (t: T) => number): number {
|
||||||
let m = 0;
|
let m = 0;
|
||||||
for (const a of arr) m = Math.max(m, sel(a));
|
for (const a of arr) m = Math.max(m, sel(a));
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scaleY(v: number, max: number): number {
|
function scaleY(v: number, max: number): number {
|
||||||
if (max <= 0) return 0;
|
if (max <= 0) return 0;
|
||||||
const innerH = chartHeight - padding.top - padding.bottom;
|
const innerH = chartHeight - padding.top - padding.bottom;
|
||||||
return (v / max) * innerH;
|
return (v / max) * innerH;
|
||||||
}
|
}
|
||||||
function barX(i: number, n: number): number {
|
|
||||||
|
function getX(i: number, n: number): number {
|
||||||
const innerW = chartWidth - padding.left - padding.right;
|
const innerW = chartWidth - padding.left - padding.right;
|
||||||
return padding.left + (innerW / n) * i + 10;
|
return padding.left + (innerW / (n - 1)) * i;
|
||||||
}
|
|
||||||
function barW(n: number): number {
|
|
||||||
const innerW = chartWidth - padding.left - padding.right;
|
|
||||||
return Math.max(8, innerW / n - 20);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sem tooltip flutuante; valores serão exibidos de forma fixa no gráfico
|
function createAreaPath(data: Array<Row>, getValue: (r: Row) => number, max: number): string {
|
||||||
|
if (data.length === 0) return "";
|
||||||
|
const n = data.length;
|
||||||
|
let path = `M ${getX(0, n)} ${chartHeight - padding.bottom}`;
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const x = getX(i, n);
|
||||||
|
const y = chartHeight - padding.bottom - scaleY(getValue(data[i]), max);
|
||||||
|
path += ` L ${x} ${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
path += ` L ${getX(n - 1, n)} ${chartHeight - padding.bottom}`;
|
||||||
|
path += " Z";
|
||||||
|
return path;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6 pb-4">
|
<div class="container mx-auto px-4 py-6 space-y-6">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="text-sm breadcrumbs">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">Dashboard</a></li>
|
||||||
|
<li><a href="/recursos-humanos">Recursos Humanos</a></li>
|
||||||
|
<li><a href="/recursos-humanos/funcionarios">Funcionários</a></li>
|
||||||
|
<li class="font-semibold">Relatórios</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<div class="p-3 bg-primary/10 rounded-xl">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-base-content">Relatórios de Funcionários</h1>
|
||||||
|
<p class="text-base-content/60">Análise de distribuição de salários e funcionários por símbolo</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if notice}
|
{#if notice}
|
||||||
<div class="alert" class:alert-error={notice.kind === "error"} class:alert-success={notice.kind === "success"}>
|
<div class="alert" class:alert-error={notice.kind === "error"} class:alert-success={notice.kind === "success"}>
|
||||||
<span>{notice.text}</span>
|
<span>{notice.text}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-3xl font-bold text-brand-dark">Relatórios</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<div class="flex justify-center items-center py-12">
|
<div class="flex justify-center items-center py-12">
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid gap-6">
|
<div class="grid gap-6">
|
||||||
<!-- Gráfico 1: Símbolo x Salário (Valor) -->
|
<!-- Gráfico 1: Símbolo x Salário (Valor) - Layer Chart -->
|
||||||
<div class="card bg-base-100 shadow overflow-hidden">
|
<div class="card bg-gradient-to-br from-base-100 to-base-200 shadow-xl border border-base-300">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h3 class="card-title">Símbolo x Salário (Valor total)</h3>
|
<div class="flex items-center gap-3 mb-4">
|
||||||
<div class="w-full overflow-x-auto">
|
<div class="p-2 bg-primary/10 rounded-lg">
|
||||||
<svg width={chartWidth} height={chartHeight} role="img" aria-label="Gráfico de barras: salário por símbolo">
|
<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="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-bold text-base-content">Distribuição de Salários por Símbolo</h3>
|
||||||
|
<p class="text-sm text-base-content/60">Valores dos símbolos cadastrados no sistema</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full overflow-x-auto bg-base-100 rounded-lg p-4">
|
||||||
|
<svg width={chartWidth} height={chartHeight} role="img" aria-label="Gráfico de área: salário por símbolo">
|
||||||
{#if rows.length === 0}
|
{#if rows.length === 0}
|
||||||
<text x="16" y="32" class="opacity-60">Sem dados</text>
|
<text x="16" y="32" class="opacity-60">Sem dados</text>
|
||||||
{:else}
|
{:else}
|
||||||
{@const max = getMax(rows, (r) => r.valor)}
|
{@const max = getMax(rows, (r) => r.valor)}
|
||||||
<!-- Eixos -->
|
|
||||||
<line x1={padding.left} y1={chartHeight - padding.bottom} x2={chartWidth - padding.right} y2={chartHeight - padding.bottom} stroke="currentColor" stroke-opacity="0.3" />
|
<!-- Grid lines -->
|
||||||
<line x1={padding.left} y1={padding.top} x2={padding.left} y2={chartHeight - padding.bottom} stroke="currentColor" stroke-opacity="0.3" />
|
|
||||||
{#each [0,1,2,3,4,5] as t}
|
{#each [0,1,2,3,4,5] as t}
|
||||||
{@const val = Math.round((max/5) * t)}
|
{@const val = Math.round((max/5) * t)}
|
||||||
{@const y = chartHeight - padding.bottom - scaleY(val, max)}
|
{@const y = chartHeight - padding.bottom - scaleY(val, max)}
|
||||||
<line x1={padding.left - 4} y1={y} x2={padding.left} y2={y} stroke="currentColor" stroke-opacity="0.3" />
|
<line x1={padding.left} y1={y} x2={chartWidth - padding.right} y2={y} stroke="currentColor" stroke-opacity="0.1" stroke-dasharray="4,4" />
|
||||||
<text x={padding.left - 8} y={y + 4} text-anchor="end" class="text-[10px] opacity-70">{`R$ ${val.toLocaleString('pt-BR')}`}</text>
|
<text x={padding.left - 8} y={y + 4} text-anchor="end" class="text-[10px] opacity-70">{`R$ ${val.toLocaleString('pt-BR')}`}</text>
|
||||||
{/each}
|
{/each}
|
||||||
<!-- Eixo X (nomes) -->
|
|
||||||
{#each rows as r, i}
|
<!-- Eixos -->
|
||||||
<text x={barX(i, rows.length) + barW(rows.length) / 2} y={chartHeight - padding.bottom + 28} text-anchor="middle" class="text-xs">
|
<line x1={padding.left} y1={chartHeight - padding.bottom} x2={chartWidth - padding.right} y2={chartHeight - padding.bottom} stroke="currentColor" stroke-opacity="0.3" stroke-width="2" />
|
||||||
{r.nome}
|
<line x1={padding.left} y1={padding.top} x2={padding.left} y2={chartHeight - padding.bottom} stroke="currentColor" stroke-opacity="0.3" stroke-width="2" />
|
||||||
</text>
|
|
||||||
{/each}
|
<!-- Area fill (camada) -->
|
||||||
<!-- Barras -->
|
<path
|
||||||
{#each rows as r, i}
|
d={createAreaPath(rows, (r) => r.valor, max)}
|
||||||
<rect
|
fill="url(#gradient-salary)"
|
||||||
x={barX(i, rows.length)}
|
opacity="0.7"
|
||||||
y={chartHeight - padding.bottom - scaleY(r.valor, max)}
|
|
||||||
width={barW(rows.length)}
|
|
||||||
height={scaleY(r.valor, max)}
|
|
||||||
class="fill-primary/80"
|
|
||||||
/>
|
/>
|
||||||
<!-- Valor fixo acima da barra -->
|
|
||||||
<text x={barX(i, rows.length) + barW(rows.length)/2} y={chartHeight - padding.bottom - scaleY(r.valor, max) - 6} text-anchor="middle" class="text-[10px] font-semibold">
|
<!-- Line -->
|
||||||
|
<polyline
|
||||||
|
points={rows.map((r, i) => {
|
||||||
|
const x = getX(i, rows.length);
|
||||||
|
const y = chartHeight - padding.bottom - scaleY(r.valor, max);
|
||||||
|
return `${x},${y}`;
|
||||||
|
}).join(' ')}
|
||||||
|
fill="none"
|
||||||
|
stroke="rgb(59, 130, 246)"
|
||||||
|
stroke-width="3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Data points -->
|
||||||
|
{#each rows as r, i}
|
||||||
|
{@const x = getX(i, rows.length)}
|
||||||
|
{@const y = chartHeight - padding.bottom - scaleY(r.valor, max)}
|
||||||
|
<circle cx={x} cy={y} r="5" fill="rgb(59, 130, 246)" stroke="white" stroke-width="2" />
|
||||||
|
<text x={x} y={y - 12} text-anchor="middle" class="text-[10px] font-semibold fill-primary">
|
||||||
{`R$ ${r.valor.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}`}
|
{`R$ ${r.valor.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}`}
|
||||||
</text>
|
</text>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
<!-- Eixo X labels -->
|
||||||
|
{#each rows as r, i}
|
||||||
|
{@const x = getX(i, rows.length)}
|
||||||
|
<foreignObject x={x - 40} y={chartHeight - padding.bottom + 15} width="80" height="70">
|
||||||
|
<div class="flex items-center justify-center text-center">
|
||||||
|
<span class="text-[11px] font-medium text-base-content/80 leading-tight" style="word-wrap: break-word; hyphens: auto;">
|
||||||
|
{r.nome}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Gradient definition -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gradient-salary" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:rgb(59, 130, 246);stop-opacity:0.8" />
|
||||||
|
<stop offset="100%" style="stop-color:rgb(59, 130, 246);stop-opacity:0.1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
{/if}
|
{/if}
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Gráfico 2: Quantidade de Funcionários por Símbolo -->
|
<!-- Gráfico 2: Quantidade de Funcionários por Símbolo - Layer Chart -->
|
||||||
<div class="card bg-base-100 shadow overflow-hidden">
|
<div class="card bg-gradient-to-br from-base-100 to-base-200 shadow-xl border border-base-300">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h3 class="card-title">Quantidade de Funcionários por Símbolo</h3>
|
<div class="flex items-center gap-3 mb-4">
|
||||||
<div class="w-full overflow-x-auto">
|
<div class="p-2 bg-secondary/10 rounded-lg">
|
||||||
<svg width={chartWidth} height={chartHeight} role="img" aria-label="Gráfico de barras: quantidade por símbolo">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-bold text-base-content">Distribuição de Funcionários por Símbolo</h3>
|
||||||
|
<p class="text-sm text-base-content/60">Quantidade de funcionários alocados em cada símbolo</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full overflow-x-auto bg-base-100 rounded-lg p-4">
|
||||||
|
<svg width={chartWidth} height={chartHeight} role="img" aria-label="Gráfico de área: quantidade por símbolo">
|
||||||
{#if rows.length === 0}
|
{#if rows.length === 0}
|
||||||
<text x="16" y="32" class="opacity-60">Sem dados</text>
|
<text x="16" y="32" class="opacity-60">Sem dados</text>
|
||||||
{:else}
|
{:else}
|
||||||
{@const maxC = getMax(rows, (r) => r.count)}
|
{@const maxC = getMax(rows, (r) => r.count)}
|
||||||
<!-- Eixos -->
|
|
||||||
<line x1={padding.left} y1={chartHeight - padding.bottom} x2={chartWidth - padding.right} y2={chartHeight - padding.bottom} stroke="currentColor" stroke-opacity="0.3" />
|
<!-- Grid lines -->
|
||||||
<line x1={padding.left} y1={padding.top} x2={padding.left} y2={chartHeight - padding.bottom} stroke="currentColor" stroke-opacity="0.3" />
|
|
||||||
{#each [0,1,2,3,4,5] as t}
|
{#each [0,1,2,3,4,5] as t}
|
||||||
{@const val = Math.round((maxC/5) * t)}
|
{@const val = Math.round((maxC/5) * t)}
|
||||||
{@const y = chartHeight - padding.bottom - scaleY(val, Math.max(1, maxC))}
|
{@const y = chartHeight - padding.bottom - scaleY(val, Math.max(1, maxC))}
|
||||||
<line x1={padding.left - 4} y1={y} x2={padding.left} y2={y} stroke="currentColor" stroke-opacity="0.3" />
|
<line x1={padding.left} y1={y} x2={chartWidth - padding.right} y2={y} stroke="currentColor" stroke-opacity="0.1" stroke-dasharray="4,4" />
|
||||||
<text x={padding.left - 6} y={y + 4} text-anchor="end" class="text-[10px] opacity-70">{val}</text>
|
<text x={padding.left - 6} y={y + 4} text-anchor="end" class="text-[10px] opacity-70">{val}</text>
|
||||||
{/each}
|
{/each}
|
||||||
{#each rows as r, i}
|
|
||||||
<text x={barX(i, rows.length) + barW(rows.length) / 2} y={chartHeight - padding.bottom + 28} text-anchor="middle" class="text-xs">
|
<!-- Eixos -->
|
||||||
{r.nome}
|
<line x1={padding.left} y1={chartHeight - padding.bottom} x2={chartWidth - padding.right} y2={chartHeight - padding.bottom} stroke="currentColor" stroke-opacity="0.3" stroke-width="2" />
|
||||||
</text>
|
<line x1={padding.left} y1={padding.top} x2={padding.left} y2={chartHeight - padding.bottom} stroke="currentColor" stroke-opacity="0.3" stroke-width="2" />
|
||||||
{/each}
|
|
||||||
{#each rows as r, i}
|
<!-- Area fill (camada) -->
|
||||||
<rect
|
<path
|
||||||
x={barX(i, rows.length)}
|
d={createAreaPath(rows, (r) => r.count, Math.max(1, maxC))}
|
||||||
y={chartHeight - padding.bottom - scaleY(r.count, Math.max(1, maxC))}
|
fill="url(#gradient-count)"
|
||||||
width={barW(rows.length)}
|
opacity="0.7"
|
||||||
height={scaleY(r.count, Math.max(1, maxC))}
|
|
||||||
class="fill-secondary/80"
|
|
||||||
/>
|
/>
|
||||||
<text x={barX(i, rows.length) + barW(rows.length)/2} y={chartHeight - padding.bottom - scaleY(r.count, Math.max(1, maxC)) - 6} text-anchor="middle" class="text-[10px] font-semibold">
|
|
||||||
|
<!-- Line -->
|
||||||
|
<polyline
|
||||||
|
points={rows.map((r, i) => {
|
||||||
|
const x = getX(i, rows.length);
|
||||||
|
const y = chartHeight - padding.bottom - scaleY(r.count, Math.max(1, maxC));
|
||||||
|
return `${x},${y}`;
|
||||||
|
}).join(' ')}
|
||||||
|
fill="none"
|
||||||
|
stroke="rgb(236, 72, 153)"
|
||||||
|
stroke-width="3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Data points -->
|
||||||
|
{#each rows as r, i}
|
||||||
|
{@const x = getX(i, rows.length)}
|
||||||
|
{@const y = chartHeight - padding.bottom - scaleY(r.count, Math.max(1, maxC))}
|
||||||
|
<circle cx={x} cy={y} r="5" fill="rgb(236, 72, 153)" stroke="white" stroke-width="2" />
|
||||||
|
<text x={x} y={y - 12} text-anchor="middle" class="text-[10px] font-semibold fill-secondary">
|
||||||
{r.count}
|
{r.count}
|
||||||
</text>
|
</text>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
<!-- Eixo X labels -->
|
||||||
|
{#each rows as r, i}
|
||||||
|
{@const x = getX(i, rows.length)}
|
||||||
|
<foreignObject x={x - 40} y={chartHeight - padding.bottom + 15} width="80" height="70">
|
||||||
|
<div class="flex items-center justify-center text-center">
|
||||||
|
<span class="text-[11px] font-medium text-base-content/80 leading-tight" style="word-wrap: break-word; hyphens: auto;">
|
||||||
|
{r.nome}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Gradient definition -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gradient-count" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:rgb(236, 72, 153);stop-opacity:0.8" />
|
||||||
|
<stop offset="100%" style="stop-color:rgb(236, 72, 153);stop-opacity:0.1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
{/if}
|
{/if}
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -73,47 +73,112 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6 pb-32">
|
<main class="container mx-auto px-4 py-4">
|
||||||
{#if notice}
|
<!-- Breadcrumb -->
|
||||||
<div class="alert" class:alert-success={notice.kind === "success"} class:alert-error={notice.kind === "error"}>
|
<div class="text-sm breadcrumbs mb-4">
|
||||||
<span>{notice.text}</span>
|
<ul>
|
||||||
|
<li><a href="/recursos-humanos" class="text-primary hover:underline">Recursos Humanos</a></li>
|
||||||
|
<li>Símbolos</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
<div class="flex justify-between items-center">
|
<!-- Cabeçalho -->
|
||||||
<h2 class="text-3xl font-bold text-brand-dark">Símbolos</h2>
|
<div class="mb-6">
|
||||||
<a href="/recursos-humanos/simbolos/cadastro" class="btn btn-primary">
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
<svg
|
<div class="flex items-center gap-4">
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<div class="p-3 bg-green-500/20 rounded-xl">
|
||||||
class="h-5 w-5"
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
viewBox="0 0 20 20"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
|
||||||
fill="currentColor"
|
</svg>
|
||||||
>
|
</div>
|
||||||
<path
|
<div>
|
||||||
fill-rule="evenodd"
|
<h1 class="text-3xl font-bold text-primary">Símbolos Cadastrados</h1>
|
||||||
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
|
<p class="text-base-content/70">Gerencie cargos comissionados e funções gratificadas</p>
|
||||||
clip-rule="evenodd"
|
</div>
|
||||||
/>
|
</div>
|
||||||
|
<a href="/recursos-humanos/simbolos/cadastro" class="btn btn-primary btn-lg gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
Novo Símbolo
|
Novo Símbolo
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid md:grid-cols-3 gap-3 items-end">
|
<!-- Alertas -->
|
||||||
|
{#if notice}
|
||||||
|
<div class="alert mb-6 shadow-lg" class:alert-success={notice.kind === "success"} class:alert-error={notice.kind === "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 notice.kind === "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>{notice.text}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Filtros -->
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-lg mb-4">
|
||||||
|
<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 de Pesquisa
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label" for="symbol_nome"><span class="label-text">Nome</span></label>
|
<label class="label" for="symbol_nome">
|
||||||
<input id="symbol_nome" class="input input-bordered" bind:value={filtroNome} />
|
<span class="label-text font-semibold">Nome do Símbolo</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="symbol_nome"
|
||||||
|
class="input input-bordered focus:input-primary"
|
||||||
|
placeholder="Buscar por nome..."
|
||||||
|
bind:value={filtroNome}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label" for="symbol_tipo"><span class="label-text">Tipo</span></label>
|
<label class="label" for="symbol_tipo">
|
||||||
<select id="symbol_tipo" class="select select-bordered" bind:value={filtroTipo}>
|
<span class="label-text font-semibold">Tipo</span>
|
||||||
<option value="">Todos</option>
|
</label>
|
||||||
<option value="cargo_comissionado">Cargo comissionado</option>
|
<select id="symbol_tipo" class="select select-bordered focus:select-primary" bind:value={filtroTipo}>
|
||||||
<option value="funcao_gratificada">Função gratificada</option>
|
<option value="">Todos os tipos</option>
|
||||||
|
<option value="cargo_comissionado">Cargo Comissionado</option>
|
||||||
|
<option value="funcao_gratificada">Função Gratificada</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label" for="symbol_desc"><span class="label-text">Descrição</span></label>
|
<label class="label" for="symbol_desc">
|
||||||
<input id="symbol_desc" class="input input-bordered" bind:value={filtroDescricao} />
|
<span class="label-text font-semibold">Descrição</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="symbol_desc"
|
||||||
|
class="input input-bordered focus:input-primary"
|
||||||
|
placeholder="Buscar na descrição..."
|
||||||
|
bind:value={filtroDescricao}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if filtroNome || filtroTipo || filtroDescricao}
|
||||||
|
<div class="mt-4">
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm gap-2"
|
||||||
|
onclick={() => {
|
||||||
|
filtroNome = "";
|
||||||
|
filtroTipo = "";
|
||||||
|
filtroDescricao = "";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
Limpar Filtros
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,17 +187,21 @@
|
|||||||
<span class="loading loading-spinner loading-lg"></span>
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="overflow-x-auto bg-base-100 rounded-lg shadow-sm mb-2" class:overflow-y-auto={needsScroll} style={needsScroll ? "max-height: calc(100vh - 180px);" : "overflow-y: visible;"}>
|
<!-- Tabela de Símbolos -->
|
||||||
<table class="table table-zebra">
|
<div class="card bg-base-100 shadow-xl">
|
||||||
<thead>
|
<div class="card-body p-0">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div class="overflow-y-auto" style="max-height: {filtered.length > 8 ? '600px' : 'none'};">
|
||||||
|
<table class="table table-zebra w-full">
|
||||||
|
<thead class="sticky top-0 bg-base-200 z-10">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Nome</th>
|
<th class="font-bold">Nome</th>
|
||||||
<th>Tipo</th>
|
<th class="font-bold">Tipo</th>
|
||||||
<th>Valor Referência</th>
|
<th class="font-bold">Valor Referência</th>
|
||||||
<th>Valor Vencimento</th>
|
<th class="font-bold">Valor Vencimento</th>
|
||||||
<th>Valor Total</th>
|
<th class="font-bold">Valor Total</th>
|
||||||
<th>Descrição</th>
|
<th class="font-bold">Descrição</th>
|
||||||
<th class="text-right">Ações</th>
|
<th class="text-right font-bold">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -207,14 +276,22 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="text-center opacity-70">Nenhum símbolo encontrado com os filtros atuais.</td>
|
<td colspan="7" class="text-center opacity-70 py-8">Nenhum símbolo encontrado com os filtros atuais.</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/if}
|
{/if}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Informação sobre resultados -->
|
||||||
|
<div class="mt-4 text-sm text-base-content/70 text-center">
|
||||||
|
Exibindo {filtered.length} de {list.length} símbolo(s)
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
<!-- Modal de Confirmação de Exclusão -->
|
<!-- Modal de Confirmação de Exclusão -->
|
||||||
<dialog id="delete_modal" class="modal">
|
<dialog id="delete_modal" class="modal">
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let notice = $state<{ kind: "success" | "error"; text: string } | null>(null);
|
let notice = $state<{ kind: "success" | "error"; text: string } | null>(null);
|
||||||
|
|
||||||
function getTotalPreview(): string {
|
function getTotalPreview(): string {
|
||||||
if (tipo !== "cargo_comissionado") return "";
|
if (tipo !== "cargo_comissionado") return "";
|
||||||
const r = unmaskCurrencyToDotDecimal(form.getFieldValue("refValor"));
|
const r = unmaskCurrencyToDotDecimal(form.getFieldValue("refValor"));
|
||||||
@@ -77,23 +78,67 @@
|
|||||||
valor: !isCargo ? unmaskCurrencyToDotDecimal(value.valor) : undefined,
|
valor: !isCargo ? unmaskCurrencyToDotDecimal(value.valor) : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
const res = await client.mutation(api.simbolos.create, payload);
|
const res = await client.mutation(api.simbolos.create, payload);
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
formApi.reset();
|
formApi.reset();
|
||||||
notice = { kind: "success", text: "Símbolo cadastrado com sucesso." };
|
notice = { kind: "success", text: "Símbolo cadastrado com sucesso!" };
|
||||||
setTimeout(() => goto("/recursos-humanos/simbolos"), 600);
|
setTimeout(() => goto("/recursos-humanos/simbolos"), 1500);
|
||||||
} else {
|
}
|
||||||
console.log("erro ao registrar cliente");
|
} catch (error: any) {
|
||||||
notice = { kind: "error", text: "Erro ao cadastrar símbolo." };
|
notice = { kind: "error", text: error.message || "Erro ao cadastrar símbolo." };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
defaultValues,
|
defaultValues,
|
||||||
}));
|
}));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-4 max-w-4xl">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="text-sm breadcrumbs mb-4">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/recursos-humanos" class="text-primary hover:underline">Recursos Humanos</a></li>
|
||||||
|
<li><a href="/recursos-humanos/simbolos" class="text-primary hover:underline">Símbolos</a></li>
|
||||||
|
<li>Cadastrar</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cabeçalho -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-4 mb-2">
|
||||||
|
<div class="p-3 bg-green-500/20 rounded-xl">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-primary">Cadastro de Símbolo</h1>
|
||||||
|
<p class="text-base-content/70">Preencha os campos abaixo para cadastrar um novo cargo ou função</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alertas -->
|
||||||
|
{#if notice}
|
||||||
|
<div
|
||||||
|
class="alert mb-6 shadow-lg"
|
||||||
|
class:alert-success={notice.kind === "success"}
|
||||||
|
class:alert-error={notice.kind === "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 notice.kind === "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>{notice.text}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Formulário -->
|
||||||
<form
|
<form
|
||||||
class="max-w-3xl mx-auto p-4"
|
class="space-y-6"
|
||||||
onsubmit={(e) => {
|
onsubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -102,87 +147,71 @@
|
|||||||
>
|
>
|
||||||
<div class="card bg-base-100 shadow-xl">
|
<div class="card bg-base-100 shadow-xl">
|
||||||
<div class="card-body space-y-6">
|
<div class="card-body space-y-6">
|
||||||
{#if notice}
|
<h2 class="card-title text-xl border-b pb-3">Informações Básicas</h2>
|
||||||
<div
|
|
||||||
class="alert"
|
|
||||||
class:alert-success={notice.kind === "success"}
|
|
||||||
class:alert-error={notice.kind === "error"}
|
|
||||||
>
|
|
||||||
<span>{notice.text}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div>
|
|
||||||
<h2 class="card-title text-3xl">Cadastro de Símbolos</h2>
|
|
||||||
<p class="opacity-70">
|
|
||||||
Preencha os campos abaixo para cadastrar um novo símbolo.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- Nome do Símbolo -->
|
||||||
<form.Field name="nome" validators={{ onChange: schema.shape.nome }}>
|
<form.Field name="nome" validators={{ onChange: schema.shape.nome }}>
|
||||||
{#snippet children({ name, state, handleChange })}
|
{#snippet children({ name, state, handleChange })}
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label" for="nome">
|
<label class="label" for="nome">
|
||||||
<span class="label-text font-medium"
|
<span class="label-text font-semibold">
|
||||||
>Símbolo <span class="text-error">*</span></span
|
Nome do Símbolo <span class="text-error">*</span>
|
||||||
>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
{name}
|
{name}
|
||||||
|
id="nome"
|
||||||
value={state.value}
|
value={state.value}
|
||||||
placeholder="Ex.: DAS-1"
|
placeholder="Ex.: DAS-1, CAA-2, FDA-3"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full focus:input-primary"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
oninput={(e) => {
|
oninput={(e) => {
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
const value = target.value;
|
handleChange(target.value);
|
||||||
handleChange(value);
|
|
||||||
}}
|
}}
|
||||||
required
|
required
|
||||||
aria-required="true"
|
|
||||||
/>
|
/>
|
||||||
<div class="label">
|
<label class="label">
|
||||||
<span class="label-text-alt opacity-60"
|
<span class="label-text-alt text-base-content/60">
|
||||||
>Informe o nome identificador do símbolo.</span
|
Informe o código identificador do símbolo
|
||||||
>
|
</span>
|
||||||
</div>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
|
||||||
<form.Field
|
<!-- Descrição -->
|
||||||
name="descricao"
|
<form.Field name="descricao" validators={{ onChange: schema.shape.descricao }}>
|
||||||
validators={{ onChange: schema.shape.descricao }}
|
|
||||||
>
|
|
||||||
{#snippet children({ name, state, handleChange })}
|
{#snippet children({ name, state, handleChange })}
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label" for="descricao">
|
<label class="label" for="descricao">
|
||||||
<span class="label-text font-medium"
|
<span class="label-text font-semibold">
|
||||||
>Descrição <span class="text-error">*</span></span
|
Descrição <span class="text-error">*</span>
|
||||||
>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<textarea
|
||||||
{name}
|
{name}
|
||||||
|
id="descricao"
|
||||||
value={state.value}
|
value={state.value}
|
||||||
placeholder="Ex.: Cargo de Apoio 1"
|
placeholder="Ex.: Cargo de Direção e Assessoramento Superior - Nível 1"
|
||||||
class="input input-bordered w-full"
|
class="textarea textarea-bordered w-full h-24 focus:textarea-primary"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
oninput={(e) => {
|
oninput={(e) => {
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLTextAreaElement;
|
||||||
const value = target.value;
|
handleChange(target.value);
|
||||||
handleChange(value);
|
|
||||||
}}
|
}}
|
||||||
required
|
required
|
||||||
aria-required="true"
|
></textarea>
|
||||||
/>
|
<label class="label">
|
||||||
<div class="label">
|
<span class="label-text-alt text-base-content/60">
|
||||||
<span class="label-text-alt opacity-60"
|
Descreva detalhadamente o símbolo
|
||||||
>Descreva brevemente o símbolo.</span
|
</span>
|
||||||
>
|
</label>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
|
||||||
|
<!-- Tipo -->
|
||||||
<form.Field
|
<form.Field
|
||||||
name="tipo"
|
name="tipo"
|
||||||
validators={{
|
validators={{
|
||||||
@@ -192,31 +221,48 @@
|
|||||||
{#snippet children({ name, state, handleChange })}
|
{#snippet children({ name, state, handleChange })}
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label" for="tipo">
|
<label class="label" for="tipo">
|
||||||
<span class="label-text font-medium"
|
<span class="label-text font-semibold">
|
||||||
>Tipo <span class="text-error">*</span></span
|
Tipo <span class="text-error">*</span>
|
||||||
>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
{name}
|
{name}
|
||||||
class="select select-bordered w-full"
|
id="tipo"
|
||||||
|
class="select select-bordered w-full focus:select-primary"
|
||||||
bind:value={tipo}
|
bind:value={tipo}
|
||||||
oninput={(e) => {
|
oninput={(e) => {
|
||||||
const target = e.target as HTMLSelectElement;
|
const target = e.target as HTMLSelectElement;
|
||||||
const value = target.value;
|
handleChange(target.value);
|
||||||
handleChange(value);
|
|
||||||
}}
|
}}
|
||||||
required
|
required
|
||||||
aria-required="true"
|
|
||||||
>
|
>
|
||||||
<option value="cargo_comissionado">Cargo comissionado</option>
|
<option value="cargo_comissionado">Cargo Comissionado (CC)</option>
|
||||||
<option value="funcao_gratificada">Função gratificada</option>
|
<option value="funcao_gratificada">Função Gratificada (FG)</option>
|
||||||
</select>
|
</select>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text-alt text-base-content/60">
|
||||||
|
Selecione se é um cargo comissionado ou função gratificada
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card de Valores -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body space-y-6">
|
||||||
|
<h2 class="card-title text-xl border-b pb-3">
|
||||||
|
Valores Financeiros
|
||||||
|
<span class="badge badge-primary badge-lg ml-2">
|
||||||
|
{tipo === "cargo_comissionado" ? "Cargo Comissionado" : "Função Gratificada"}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
{#if tipo === "cargo_comissionado"}
|
{#if tipo === "cargo_comissionado"}
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Valor de Vencimento -->
|
||||||
<form.Field
|
<form.Field
|
||||||
name="vencValor"
|
name="vencValor"
|
||||||
validators={{
|
validators={{
|
||||||
@@ -229,15 +275,18 @@
|
|||||||
{#snippet children({ name, state, handleChange })}
|
{#snippet children({ name, state, handleChange })}
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label" for="vencValor">
|
<label class="label" for="vencValor">
|
||||||
<span class="label-text font-medium"
|
<span class="label-text font-semibold">
|
||||||
>Valor de Vencimento <span class="text-error">*</span></span
|
Valor de Vencimento <span class="text-error">*</span>
|
||||||
>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="input-group">
|
||||||
|
<span>R$</span>
|
||||||
<input
|
<input
|
||||||
{name}
|
{name}
|
||||||
|
id="vencValor"
|
||||||
value={state.value}
|
value={state.value}
|
||||||
placeholder="Ex.: 1200,00"
|
placeholder="0,00"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full focus:input-primary"
|
||||||
inputmode="decimal"
|
inputmode="decimal"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
oninput={(e) => {
|
oninput={(e) => {
|
||||||
@@ -247,17 +296,18 @@
|
|||||||
handleChange(formatted);
|
handleChange(formatted);
|
||||||
}}
|
}}
|
||||||
required
|
required
|
||||||
aria-required="true"
|
|
||||||
/>
|
/>
|
||||||
<div class="label">
|
</label>
|
||||||
<span class="label-text-alt opacity-60"
|
<label class="label">
|
||||||
>Valor efetivo de vencimento.</span
|
<span class="label-text-alt text-base-content/60">
|
||||||
>
|
Valor base de vencimento
|
||||||
</div>
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
|
|
||||||
|
<!-- Valor de Referência -->
|
||||||
<form.Field
|
<form.Field
|
||||||
name="refValor"
|
name="refValor"
|
||||||
validators={{
|
validators={{
|
||||||
@@ -270,15 +320,18 @@
|
|||||||
{#snippet children({ name, state, handleChange })}
|
{#snippet children({ name, state, handleChange })}
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label" for="refValor">
|
<label class="label" for="refValor">
|
||||||
<span class="label-text font-medium"
|
<span class="label-text font-semibold">
|
||||||
>Valor de Referência <span class="text-error">*</span></span
|
Valor de Referência <span class="text-error">*</span>
|
||||||
>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="input-group">
|
||||||
|
<span>R$</span>
|
||||||
<input
|
<input
|
||||||
{name}
|
{name}
|
||||||
|
id="refValor"
|
||||||
value={state.value}
|
value={state.value}
|
||||||
placeholder="Ex.: 1000,00"
|
placeholder="0,00"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full focus:input-primary"
|
||||||
inputmode="decimal"
|
inputmode="decimal"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
oninput={(e) => {
|
oninput={(e) => {
|
||||||
@@ -288,23 +341,32 @@
|
|||||||
handleChange(formatted);
|
handleChange(formatted);
|
||||||
}}
|
}}
|
||||||
required
|
required
|
||||||
aria-required="true"
|
|
||||||
/>
|
/>
|
||||||
<div class="label">
|
</label>
|
||||||
<span class="label-text-alt opacity-60"
|
<label class="label">
|
||||||
>Valor base de referência.</span
|
<span class="label-text-alt text-base-content/60">
|
||||||
>
|
Valor de referência do cargo
|
||||||
</div>
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview do Total -->
|
||||||
{#if getTotalPreview()}
|
{#if getTotalPreview()}
|
||||||
<div class="alert bg-base-200">
|
<div class="alert alert-info shadow-lg">
|
||||||
<span>Total previsto: R$ {getTotalPreview()}</span>
|
<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">Valor Total Calculado</h3>
|
||||||
|
<div class="text-2xl font-bold mt-1">R$ {getTotalPreview()}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
|
<!-- Valor da Função Gratificada -->
|
||||||
<form.Field
|
<form.Field
|
||||||
name="valor"
|
name="valor"
|
||||||
validators={{
|
validators={{
|
||||||
@@ -317,15 +379,18 @@
|
|||||||
{#snippet children({ name, state, handleChange })}
|
{#snippet children({ name, state, handleChange })}
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label" for="valor">
|
<label class="label" for="valor">
|
||||||
<span class="label-text font-medium"
|
<span class="label-text font-semibold">
|
||||||
>Valor <span class="text-error">*</span></span
|
Valor da Função Gratificada <span class="text-error">*</span>
|
||||||
>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="input-group">
|
||||||
|
<span>R$</span>
|
||||||
<input
|
<input
|
||||||
{name}
|
{name}
|
||||||
|
id="valor"
|
||||||
value={state.value}
|
value={state.value}
|
||||||
placeholder="Ex.: 1.500,00"
|
placeholder="0,00"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full focus:input-primary"
|
||||||
inputmode="decimal"
|
inputmode="decimal"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
oninput={(e) => {
|
oninput={(e) => {
|
||||||
@@ -335,18 +400,21 @@
|
|||||||
handleChange(formatted);
|
handleChange(formatted);
|
||||||
}}
|
}}
|
||||||
required
|
required
|
||||||
aria-required="true"
|
|
||||||
/>
|
/>
|
||||||
<div class="label">
|
</label>
|
||||||
<span class="label-text-alt opacity-60"
|
<label class="label">
|
||||||
>Informe o valor da função gratificada.</span
|
<span class="label-text-alt text-base-content/60">
|
||||||
>
|
Valor mensal da função gratificada
|
||||||
</div>
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</form.Field>
|
</form.Field>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Botões de Ação -->
|
||||||
<form.Subscribe
|
<form.Subscribe
|
||||||
selector={(state) => ({
|
selector={(state) => ({
|
||||||
canSubmit: state.canSubmit,
|
canSubmit: state.canSubmit,
|
||||||
@@ -354,26 +422,56 @@
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{#snippet children({ canSubmit, isSubmitting })}
|
{#snippet children({ canSubmit, isSubmitting })}
|
||||||
<div class="card-actions justify-end pt-2">
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3 justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost"
|
class="btn btn-ghost btn-lg"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onclick={() => goto("/recursos-humanos/simbolos")}
|
onclick={() => goto("/recursos-humanos/simbolos")}
|
||||||
>
|
>
|
||||||
|
<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
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary btn-lg"
|
||||||
class:loading={isSubmitting}
|
|
||||||
disabled={isSubmitting || !canSubmit}
|
disabled={isSubmitting || !canSubmit}
|
||||||
>
|
>
|
||||||
<span>Cadastrar Símbolo</span>
|
{#if isSubmitting}
|
||||||
|
<span class="loading loading-spinner"></span>
|
||||||
|
Cadastrando...
|
||||||
|
{: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>
|
||||||
|
Cadastrar Símbolo
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</form.Subscribe>
|
</form.Subscribe>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-4">
|
||||||
|
<div class="text-sm breadcrumbs mb-4">
|
||||||
|
<ul>
|
||||||
|
<li><a href="/" class="text-primary hover:underline">Dashboard</a></li>
|
||||||
|
<li>Secretaria Executiva</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-4 mb-2">
|
||||||
|
<div class="p-3 bg-indigo-500/20 rounded-xl">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-primary">Secretaria Executiva</h1>
|
||||||
|
<p class="text-base-content/70">Gestão executiva e administrativa</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div class="mb-6">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 text-base-content/20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
|
||||||
|
<p class="text-base-content/70 max-w-md mb-6">
|
||||||
|
O módulo da Secretaria Executiva está sendo desenvolvido e em breve estará disponível com funcionalidades completas de gestão executiva e administrativa.
|
||||||
|
</p>
|
||||||
|
<div class="badge badge-warning badge-lg gap-2">
|
||||||
|
<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 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
|
</svg>
|
||||||
|
Em Desenvolvimento
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
259
apps/web/src/routes/(dashboard)/solicitar-acesso/+page.svelte
Normal file
259
apps/web/src/routes/(dashboard)/solicitar-acesso/+page.svelte
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { useConvexClient } from "convex-svelte";
|
||||||
|
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||||
|
import { createForm } from "@tanstack/svelte-form";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
const convex = useConvexClient();
|
||||||
|
|
||||||
|
// Estado para mensagens
|
||||||
|
let notice = $state<{ type: "success" | "error"; message: string } | null>(null);
|
||||||
|
|
||||||
|
// Schema de validação
|
||||||
|
const formSchema = z.object({
|
||||||
|
nome: z.string().min(3, "Nome deve ter no mínimo 3 caracteres"),
|
||||||
|
matricula: z.string().min(1, "Matrícula é obrigatória"),
|
||||||
|
email: z.string().email("E-mail inválido"),
|
||||||
|
telefone: z.string().min(14, "Telefone inválido"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Criar o formulário
|
||||||
|
const form = createForm(() => ({
|
||||||
|
defaultValues: {
|
||||||
|
nome: "",
|
||||||
|
matricula: "",
|
||||||
|
email: "",
|
||||||
|
telefone: "",
|
||||||
|
},
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
try {
|
||||||
|
notice = null;
|
||||||
|
await convex.mutation(api.solicitacoesAcesso.create, {
|
||||||
|
nome: value.nome,
|
||||||
|
matricula: value.matricula,
|
||||||
|
email: value.email,
|
||||||
|
telefone: value.telefone,
|
||||||
|
});
|
||||||
|
notice = {
|
||||||
|
type: "success",
|
||||||
|
message: "Solicitação de acesso enviada com sucesso! Aguarde a análise da equipe de TI.",
|
||||||
|
};
|
||||||
|
// Limpar o formulário
|
||||||
|
form.reset();
|
||||||
|
// Redirecionar após 3 segundos
|
||||||
|
setTimeout(() => {
|
||||||
|
goto("/");
|
||||||
|
}, 3000);
|
||||||
|
} catch (error: any) {
|
||||||
|
notice = {
|
||||||
|
type: "error",
|
||||||
|
message: error.message || "Erro ao enviar solicitação. Tente novamente.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Máscaras
|
||||||
|
function maskTelefone(value: string): string {
|
||||||
|
const cleaned = value.replace(/\D/g, "");
|
||||||
|
if (cleaned.length <= 10) {
|
||||||
|
return cleaned
|
||||||
|
.replace(/^(\d{2})(\d)/, "($1) $2")
|
||||||
|
.replace(/(\d{4})(\d)/, "$1-$2");
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
.replace(/^(\d{2})(\d)/, "($1) $2")
|
||||||
|
.replace(/(\d{5})(\d)/, "$1-$2");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
goto("/");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-4 max-w-4xl">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-3xl font-bold text-primary mb-2">Solicitar Acesso ao SGSE</h1>
|
||||||
|
<p class="text-base-content/70">
|
||||||
|
Preencha o formulário abaixo para solicitar acesso ao Sistema de Gerenciamento da Secretaria de Esportes.
|
||||||
|
Sua solicitação será analisada pela equipe de Tecnologia da Informação.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if notice}
|
||||||
|
<div class="alert {notice.type === 'success' ? 'alert-success' : 'alert-error'} mb-6">
|
||||||
|
<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 notice.type === "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>{notice.message}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<form
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
form.handleSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<!-- Nome -->
|
||||||
|
<form.Field name="nome" validators={{ onChange: formSchema.shape.nome }}>
|
||||||
|
{#snippet children(field)}
|
||||||
|
<div class="form-control md:col-span-2">
|
||||||
|
<label class="label" for="nome">
|
||||||
|
<span class="label-text">Nome Completo *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="nome"
|
||||||
|
type="text"
|
||||||
|
placeholder="Digite seu nome completo"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
value={field.state.value}
|
||||||
|
onblur={field.handleBlur}
|
||||||
|
oninput={(e) => field.handleChange(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
{#if field.state.meta.errors.length > 0}
|
||||||
|
<span class="text-error text-sm mt-1">{field.state.meta.errors[0]}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<!-- Matrícula -->
|
||||||
|
<form.Field name="matricula" validators={{ onChange: formSchema.shape.matricula }}>
|
||||||
|
{#snippet children(field)}
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="matricula">
|
||||||
|
<span class="label-text">Matrícula *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="matricula"
|
||||||
|
type="text"
|
||||||
|
placeholder="Digite sua matrícula"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
value={field.state.value}
|
||||||
|
onblur={field.handleBlur}
|
||||||
|
oninput={(e) => field.handleChange(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
{#if field.state.meta.errors.length > 0}
|
||||||
|
<span class="text-error text-sm mt-1">{field.state.meta.errors[0]}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<!-- E-mail -->
|
||||||
|
<form.Field name="email" validators={{ onChange: formSchema.shape.email }}>
|
||||||
|
{#snippet children(field)}
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="email">
|
||||||
|
<span class="label-text">E-mail *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="seu@email.com"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
value={field.state.value}
|
||||||
|
onblur={field.handleBlur}
|
||||||
|
oninput={(e) => field.handleChange(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
{#if field.state.meta.errors.length > 0}
|
||||||
|
<span class="text-error text-sm mt-1">{field.state.meta.errors[0]}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</form.Field>
|
||||||
|
|
||||||
|
<!-- Telefone -->
|
||||||
|
<form.Field name="telefone" validators={{ onChange: formSchema.shape.telefone }}>
|
||||||
|
{#snippet children(field)}
|
||||||
|
<div class="form-control md:col-span-2">
|
||||||
|
<label class="label" for="telefone">
|
||||||
|
<span class="label-text">Telefone *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="telefone"
|
||||||
|
type="text"
|
||||||
|
placeholder="(00) 00000-0000"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
value={field.state.value}
|
||||||
|
onblur={field.handleBlur}
|
||||||
|
oninput={(e) => {
|
||||||
|
const masked = maskTelefone(e.currentTarget.value);
|
||||||
|
e.currentTarget.value = masked;
|
||||||
|
field.handleChange(masked);
|
||||||
|
}}
|
||||||
|
maxlength="15"
|
||||||
|
/>
|
||||||
|
{#if field.state.meta.errors.length > 0}
|
||||||
|
<span class="text-error text-sm mt-1">{field.state.meta.errors[0]}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</form.Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-end mt-6 gap-2">
|
||||||
|
<button type="button" class="btn btn-ghost" onclick={handleCancel}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
Solicitar Acesso
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</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>
|
||||||
|
<div class="text-sm">
|
||||||
|
<ul class="list-disc list-inside mt-2">
|
||||||
|
<li>Todos os campos marcados com * são obrigatórios</li>
|
||||||
|
<li>Sua solicitação será analisada pela equipe de TI em até 48 horas úteis</li>
|
||||||
|
<li>Você receberá um e-mail com o resultado da análise</li>
|
||||||
|
<li>Em caso de dúvidas, entre em contato com o suporte técnico</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
197
apps/web/src/routes/(dashboard)/ti/+page.svelte
Normal file
197
apps/web/src/routes/(dashboard)/ti/+page.svelte
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="container mx-auto px-4 py-4">
|
||||||
|
<h1 class="text-3xl font-bold text-primary mb-6">Tecnologia da Informação</h1>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- Card Painel Administrativo -->
|
||||||
|
<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-primary/20 rounded-lg">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-8 w-8 text-primary"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="card-title text-xl">Painel Administrativo</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-base-content/70 mb-4">
|
||||||
|
Acesso restrito para gerenciamento de solicitações de acesso ao sistema e outras configurações administrativas.
|
||||||
|
</p>
|
||||||
|
<div class="card-actions justify-end">
|
||||||
|
<a href="/ti/painel-administrativo" class="btn btn-primary">
|
||||||
|
Acessar Painel
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Suporte Técnico -->
|
||||||
|
<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-primary/20 rounded-lg">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-8 w-8 text-primary"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192l-3.536 3.536M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="card-title text-xl">Suporte Técnico</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-base-content/70 mb-4">
|
||||||
|
Central de atendimento para resolução de problemas técnicos e dúvidas sobre o sistema.
|
||||||
|
</p>
|
||||||
|
<div class="card-actions justify-end">
|
||||||
|
<button class="btn btn-primary" disabled>
|
||||||
|
Em breve
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Gerenciar Permissões -->
|
||||||
|
<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-success/20 rounded-lg">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-8 w-8 text-success"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="card-title text-xl">Gerenciar Permissões</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-base-content/70 mb-4">
|
||||||
|
Configure as permissões de acesso aos menus do sistema por função. Controle quem pode acessar, consultar e gravar dados.
|
||||||
|
</p>
|
||||||
|
<div class="card-actions justify-end">
|
||||||
|
<a href="/ti/painel-permissoes" class="btn btn-success">
|
||||||
|
Configurar Permissões
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Personalizar por Matrícula -->
|
||||||
|
<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">
|
||||||
|
<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="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-xl">Personalizar por Matrícula</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.
|
||||||
|
</p>
|
||||||
|
<div class="card-actions justify-end">
|
||||||
|
<a href="/ti/personalizar-permissoes" class="btn btn-info">
|
||||||
|
Personalizar Acessos
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Documentação -->
|
||||||
|
<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-primary/20 rounded-lg">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-8 w-8 text-primary"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 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>
|
||||||
|
<h2 class="card-title text-xl">Documentação</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-base-content/70 mb-4">
|
||||||
|
Manuais, guias e documentação técnica do sistema para usuários e administradores.
|
||||||
|
</p>
|
||||||
|
<div class="card-actions justify-end">
|
||||||
|
<button class="btn btn-primary" disabled>
|
||||||
|
Em breve
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info mt-8">
|
||||||
|
<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">Área Restrita</h3>
|
||||||
|
<div class="text-sm">
|
||||||
|
Esta é uma área de acesso restrito. Apenas usuários autorizados pela equipe de TI podem acessar o Painel Administrativo.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
@@ -0,0 +1,977 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useConvexClient, useQuery } from "convex-svelte";
|
||||||
|
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||||
|
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||||
|
import { authStore } from "$lib/stores/auth.svelte";
|
||||||
|
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
|
||||||
|
|
||||||
|
const convex = useConvexClient();
|
||||||
|
|
||||||
|
// Aba ativa
|
||||||
|
let abaAtiva = $state<"solicitacoes" | "usuarios" | "logs">("solicitacoes");
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ABA 1: SOLICITAÇÕES DE ACESSO
|
||||||
|
// ========================================
|
||||||
|
const solicitacoesQuery = useQuery(api.solicitacoesAcesso.getAll, {});
|
||||||
|
let filtroStatusSolicitacao = $state<"todas" | "pendente" | "aprovado" | "rejeitado">("todas");
|
||||||
|
let filtroNomeSolicitacao = $state("");
|
||||||
|
let filtroMatriculaSolicitacao = $state("");
|
||||||
|
let modalSolicitacaoAberto = $state(false);
|
||||||
|
let solicitacaoSelecionada = $state<Id<"solicitacoesAcesso"> | null>(null);
|
||||||
|
let acaoModalSolicitacao = $state<"aprovar" | "rejeitar" | null>(null);
|
||||||
|
let observacoesSolicitacao = $state("");
|
||||||
|
|
||||||
|
let filteredSolicitacoes = $derived(() => {
|
||||||
|
if (!solicitacoesQuery.data) return [];
|
||||||
|
|
||||||
|
return solicitacoesQuery.data.filter((s: any) => {
|
||||||
|
const matchStatus = filtroStatusSolicitacao === "todas" || s.status === filtroStatusSolicitacao;
|
||||||
|
const matchNome = s.nome.toLowerCase().includes(filtroNomeSolicitacao.toLowerCase());
|
||||||
|
const matchMatricula = s.matricula.toLowerCase().includes(filtroMatriculaSolicitacao.toLowerCase());
|
||||||
|
return matchStatus && matchNome && matchMatricula;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function abrirModalSolicitacao(solicitacaoId: Id<"solicitacoesAcesso">, acao: "aprovar" | "rejeitar") {
|
||||||
|
solicitacaoSelecionada = solicitacaoId;
|
||||||
|
acaoModalSolicitacao = acao;
|
||||||
|
observacoesSolicitacao = "";
|
||||||
|
modalSolicitacaoAberto = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fecharModalSolicitacao() {
|
||||||
|
modalSolicitacaoAberto = false;
|
||||||
|
solicitacaoSelecionada = null;
|
||||||
|
acaoModalSolicitacao = null;
|
||||||
|
observacoesSolicitacao = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmarAcaoSolicitacao() {
|
||||||
|
if (!solicitacaoSelecionada || !acaoModalSolicitacao) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (acaoModalSolicitacao === "aprovar") {
|
||||||
|
await convex.mutation(api.solicitacoesAcesso.aprovar, {
|
||||||
|
solicitacaoId: solicitacaoSelecionada,
|
||||||
|
observacoes: observacoesSolicitacao || undefined,
|
||||||
|
});
|
||||||
|
mostrarNotice("success", "Solicitação aprovada com sucesso!");
|
||||||
|
} else {
|
||||||
|
await convex.mutation(api.solicitacoesAcesso.rejeitar, {
|
||||||
|
solicitacaoId: solicitacaoSelecionada,
|
||||||
|
observacoes: observacoesSolicitacao || undefined,
|
||||||
|
});
|
||||||
|
mostrarNotice("success", "Solicitação rejeitada com sucesso!");
|
||||||
|
}
|
||||||
|
fecharModalSolicitacao();
|
||||||
|
} catch (error: any) {
|
||||||
|
mostrarNotice("error", error.message || "Erro ao processar solicitação.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ABA 2: GERENCIAMENTO DE USUÁRIOS
|
||||||
|
// ========================================
|
||||||
|
const usuariosQuery = useQuery(api.usuarios.listar, {});
|
||||||
|
const rolesQuery = useQuery(api.roles.listar, {});
|
||||||
|
let filtroNomeUsuario = $state("");
|
||||||
|
let filtroMatriculaUsuario = $state("");
|
||||||
|
let filtroRoleUsuario = $state<string>("todos");
|
||||||
|
let filtroStatusUsuario = $state<"todos" | "ativo" | "inativo">("todos");
|
||||||
|
let modalUsuarioAberto = $state(false);
|
||||||
|
let usuarioSelecionado = $state<Id<"usuarios"> | null>(null);
|
||||||
|
let acaoModalUsuario = $state<"ativar" | "desativar" | "resetar" | "alterar_role" | null>(null);
|
||||||
|
let novaRoleId = $state<Id<"roles"> | null>(null);
|
||||||
|
|
||||||
|
let filteredUsuarios = $derived(() => {
|
||||||
|
if (!usuariosQuery.data) return [];
|
||||||
|
|
||||||
|
return usuariosQuery.data.filter((u: any) => {
|
||||||
|
const matchNome = u.nome.toLowerCase().includes(filtroNomeUsuario.toLowerCase());
|
||||||
|
const matchMatricula = u.matricula.toLowerCase().includes(filtroMatriculaUsuario.toLowerCase());
|
||||||
|
const matchRole = filtroRoleUsuario === "todos" || u.role.nome === filtroRoleUsuario;
|
||||||
|
const matchStatus = filtroStatusUsuario === "todos" ||
|
||||||
|
(filtroStatusUsuario === "ativo" && u.ativo) ||
|
||||||
|
(filtroStatusUsuario === "inativo" && !u.ativo);
|
||||||
|
return matchNome && matchMatricula && matchRole && matchStatus;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function abrirModalUsuario(usuarioId: Id<"usuarios">, acao: "ativar" | "desativar" | "resetar" | "alterar_role") {
|
||||||
|
usuarioSelecionado = usuarioId;
|
||||||
|
acaoModalUsuario = acao;
|
||||||
|
|
||||||
|
// Se for alterar role, pegar a role atual do usuário
|
||||||
|
if (acao === "alterar_role" && usuariosQuery.data) {
|
||||||
|
const usuario = usuariosQuery.data.find((u: any) => u._id === usuarioId);
|
||||||
|
if (usuario) {
|
||||||
|
novaRoleId = usuario.role._id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modalUsuarioAberto = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fecharModalUsuario() {
|
||||||
|
modalUsuarioAberto = false;
|
||||||
|
usuarioSelecionado = null;
|
||||||
|
acaoModalUsuario = null;
|
||||||
|
novaRoleId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmarAcaoUsuario() {
|
||||||
|
if (!usuarioSelecionado || !acaoModalUsuario) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (acaoModalUsuario === "ativar") {
|
||||||
|
await convex.mutation(api.usuarios.ativar, { id: usuarioSelecionado });
|
||||||
|
mostrarNotice("success", "Usuário ativado com sucesso!");
|
||||||
|
} else if (acaoModalUsuario === "desativar") {
|
||||||
|
await convex.mutation(api.usuarios.desativar, { id: usuarioSelecionado });
|
||||||
|
mostrarNotice("success", "Usuário desativado com sucesso!");
|
||||||
|
} else if (acaoModalUsuario === "resetar") {
|
||||||
|
await convex.mutation(api.usuarios.resetarSenha, {
|
||||||
|
usuarioId: usuarioSelecionado,
|
||||||
|
novaSenha: "Mudar@123"
|
||||||
|
});
|
||||||
|
mostrarNotice("success", "Senha resetada para 'Mudar@123' com sucesso!");
|
||||||
|
} else if (acaoModalUsuario === "alterar_role" && novaRoleId) {
|
||||||
|
await convex.mutation(api.usuarios.alterarRole, {
|
||||||
|
usuarioId: usuarioSelecionado,
|
||||||
|
novaRoleId: novaRoleId
|
||||||
|
});
|
||||||
|
mostrarNotice("success", "Função/Nível alterado com sucesso!");
|
||||||
|
}
|
||||||
|
fecharModalUsuario();
|
||||||
|
} catch (error: any) {
|
||||||
|
mostrarNotice("error", error.message || "Erro ao processar ação.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// ABA 3: HISTÓRICO DE ACESSOS
|
||||||
|
// ========================================
|
||||||
|
const logsQuery = useQuery(api.logsAcesso.listar, { limite: 100 });
|
||||||
|
let filtroTipoLog = $state<string>("todos");
|
||||||
|
let filtroUsuarioLog = $state("");
|
||||||
|
let modalLimparLogsAberto = $state(false);
|
||||||
|
|
||||||
|
let filteredLogs = $derived(() => {
|
||||||
|
if (!logsQuery.data) return [];
|
||||||
|
|
||||||
|
return logsQuery.data.filter((log: any) => {
|
||||||
|
const matchTipo = filtroTipoLog === "todos" || log.tipo === filtroTipoLog;
|
||||||
|
const matchUsuario = !filtroUsuarioLog ||
|
||||||
|
(log.usuario && log.usuario.nome.toLowerCase().includes(filtroUsuarioLog.toLowerCase()));
|
||||||
|
return matchTipo && matchUsuario;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function limparLogs() {
|
||||||
|
try {
|
||||||
|
await convex.mutation(api.logsAcesso.limparTodos, {});
|
||||||
|
mostrarNotice("success", "Histórico de logs limpo com sucesso!");
|
||||||
|
modalLimparLogsAberto = false;
|
||||||
|
} catch (error: any) {
|
||||||
|
mostrarNotice("error", error.message || "Erro ao limpar logs.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// UTILITÁRIOS
|
||||||
|
// ========================================
|
||||||
|
let notice = $state<{ type: "success" | "error"; message: string } | null>(null);
|
||||||
|
|
||||||
|
function mostrarNotice(type: "success" | "error", message: string) {
|
||||||
|
notice = { type, message };
|
||||||
|
setTimeout(() => {
|
||||||
|
notice = null;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatarData(timestamp: number): string {
|
||||||
|
return new Date(timestamp).toLocaleString("pt-BR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadgeClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "pendente":
|
||||||
|
return "badge-warning";
|
||||||
|
case "aprovado":
|
||||||
|
return "badge-success";
|
||||||
|
case "rejeitado":
|
||||||
|
return "badge-error";
|
||||||
|
default:
|
||||||
|
return "badge-ghost";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case "pendente":
|
||||||
|
return "Pendente";
|
||||||
|
case "aprovado":
|
||||||
|
return "Aprovado";
|
||||||
|
case "rejeitado":
|
||||||
|
return "Rejeitado";
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTipoLogIcon(tipo: string): string {
|
||||||
|
switch (tipo) {
|
||||||
|
case "login":
|
||||||
|
return "🔓";
|
||||||
|
case "logout":
|
||||||
|
return "🔒";
|
||||||
|
case "acesso_negado":
|
||||||
|
return "⛔";
|
||||||
|
case "senha_alterada":
|
||||||
|
return "🔑";
|
||||||
|
case "sessao_expirada":
|
||||||
|
return "⏰";
|
||||||
|
default:
|
||||||
|
return "📝";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTipoLogLabel(tipo: string): string {
|
||||||
|
switch (tipo) {
|
||||||
|
case "login":
|
||||||
|
return "Login";
|
||||||
|
case "logout":
|
||||||
|
return "Logout";
|
||||||
|
case "acesso_negado":
|
||||||
|
return "Acesso Negado";
|
||||||
|
case "senha_alterada":
|
||||||
|
return "Senha Alterada";
|
||||||
|
case "sessao_expirada":
|
||||||
|
return "Sessão Expirada";
|
||||||
|
default:
|
||||||
|
return tipo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProtectedRoute requireAuth={true} allowedRoles={["admin", "ti"]} maxLevel={1}>
|
||||||
|
<main class="container mx-auto px-4 py-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<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="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>
|
||||||
|
<h1 class="text-4xl font-bold text-primary">Painel Administrativo</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-base-content/70 text-lg">
|
||||||
|
Controle total de acesso, usuários e auditoria do sistema SGSE
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if notice}
|
||||||
|
<div class="alert {notice.type === 'success' ? 'alert-success' : 'alert-error'} mb-6">
|
||||||
|
<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 notice.type === "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>{notice.message}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="tabs tabs-boxed bg-base-200 p-2 mb-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tab {abaAtiva === 'solicitacoes' ? 'tab-active' : ''}"
|
||||||
|
onclick={() => (abaAtiva = "solicitacoes")}
|
||||||
|
>
|
||||||
|
<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 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>
|
||||||
|
Solicitações de Acesso
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tab {abaAtiva === 'usuarios' ? 'tab-active' : ''}"
|
||||||
|
onclick={() => (abaAtiva = "usuarios")}
|
||||||
|
>
|
||||||
|
<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="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>
|
||||||
|
Gerenciar Usuários
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tab {abaAtiva === 'logs' ? 'tab-active' : ''}"
|
||||||
|
onclick={() => (abaAtiva = "logs")}
|
||||||
|
>
|
||||||
|
<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 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>
|
||||||
|
Histórico de Acessos
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ABA 1: SOLICITAÇÕES -->
|
||||||
|
{#if abaAtiva === "solicitacoes"}
|
||||||
|
<!-- Estatísticas -->
|
||||||
|
{#if solicitacoesQuery.data}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div class="stat bg-gradient-to-br from-primary/20 to-primary/10 shadow-lg rounded-xl border border-primary/30">
|
||||||
|
<div class="stat-title">Total</div>
|
||||||
|
<div class="stat-value text-primary">{solicitacoesQuery.data.length}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-gradient-to-br from-warning/20 to-warning/10 shadow-lg rounded-xl border border-warning/30">
|
||||||
|
<div class="stat-title">Pendentes</div>
|
||||||
|
<div class="stat-value text-warning">
|
||||||
|
{solicitacoesQuery.data.filter((s: any) => s.status === "pendente").length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-gradient-to-br from-success/20 to-success/10 shadow-lg rounded-xl border border-success/30">
|
||||||
|
<div class="stat-title">Aprovadas</div>
|
||||||
|
<div class="stat-value text-success">
|
||||||
|
{solicitacoesQuery.data.filter((s: any) => s.status === "aprovado").length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-gradient-to-br from-error/20 to-error/10 shadow-lg rounded-xl border border-error/30">
|
||||||
|
<div class="stat-title">Rejeitadas</div>
|
||||||
|
<div class="stat-value text-error">
|
||||||
|
{solicitacoesQuery.data.filter((s: any) => s.status === "rejeitado").length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Filtros -->
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-6 border border-base-300">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-lg mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="filtro-status-solicitacao">
|
||||||
|
<span class="label-text font-semibold">Status</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="filtro-status-solicitacao"
|
||||||
|
class="select select-bordered select-primary w-full"
|
||||||
|
bind:value={filtroStatusSolicitacao}
|
||||||
|
>
|
||||||
|
<option value="todas">Todas</option>
|
||||||
|
<option value="pendente">Pendente</option>
|
||||||
|
<option value="aprovado">Aprovado</option>
|
||||||
|
<option value="rejeitado">Rejeitado</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="filtro-nome-solicitacao">
|
||||||
|
<span class="label-text font-semibold">Nome</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filtro-nome-solicitacao"
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por nome..."
|
||||||
|
class="input input-bordered input-primary w-full"
|
||||||
|
bind:value={filtroNomeSolicitacao}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="filtro-matricula-solicitacao">
|
||||||
|
<span class="label-text font-semibold">Matrícula</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filtro-matricula-solicitacao"
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por matrícula..."
|
||||||
|
class="input input-bordered input-primary w-full"
|
||||||
|
bind:value={filtroMatriculaSolicitacao}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabela de Solicitações -->
|
||||||
|
<div class="card bg-base-100 shadow-xl border border-base-300">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{#if solicitacoesQuery.isLoading}
|
||||||
|
<div class="flex justify-center items-center p-12">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
{:else if solicitacoesQuery.error}
|
||||||
|
<div class="alert alert-error m-4">
|
||||||
|
<span>Erro ao carregar solicitações: {solicitacoesQuery.error.message}</span>
|
||||||
|
</div>
|
||||||
|
{:else if filteredSolicitacoes().length === 0}
|
||||||
|
<div class="text-center py-12 text-base-content/70">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-lg font-semibold">Nenhuma solicitação encontrada</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-zebra w-full">
|
||||||
|
<thead class="bg-base-200">
|
||||||
|
<tr>
|
||||||
|
<th>Data</th>
|
||||||
|
<th>Nome</th>
|
||||||
|
<th>Matrícula</th>
|
||||||
|
<th>E-mail</th>
|
||||||
|
<th>Telefone</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Resposta</th>
|
||||||
|
<th>Ações</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredSolicitacoes() as solicitacao (solicitacao._id)}
|
||||||
|
<tr class="hover">
|
||||||
|
<td class="font-mono text-sm">{formatarData(solicitacao.dataSolicitacao)}</td>
|
||||||
|
<td class="font-semibold">{solicitacao.nome}</td>
|
||||||
|
<td><code class="bg-base-200 px-2 py-1 rounded">{solicitacao.matricula}</code></td>
|
||||||
|
<td class="text-sm">{solicitacao.email}</td>
|
||||||
|
<td class="text-sm">{solicitacao.telefone}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {getStatusBadgeClass(solicitacao.status)} badge-lg">
|
||||||
|
{getStatusLabel(solicitacao.status)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="font-mono text-sm">
|
||||||
|
{solicitacao.dataResposta ? formatarData(solicitacao.dataResposta) : "-"}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if solicitacao.status === "pendente"}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-success btn-sm"
|
||||||
|
onclick={() => abrirModalSolicitacao(solicitacao._id, "aprovar")}
|
||||||
|
>
|
||||||
|
<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="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
Aprovar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-error btn-sm"
|
||||||
|
onclick={() => abrirModalSolicitacao(solicitacao._id, "rejeitar")}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
Rejeitar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<span class="text-base-content/50 text-sm italic">
|
||||||
|
{solicitacao.observacoes || "Sem observações"}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ABA 2: USUÁRIOS -->
|
||||||
|
{#if abaAtiva === "usuarios"}
|
||||||
|
<!-- Estatísticas -->
|
||||||
|
{#if usuariosQuery.data}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
|
<div class="stat bg-gradient-to-br from-primary/20 to-primary/10 shadow-lg rounded-xl border border-primary/30">
|
||||||
|
<div class="stat-title">Total de Usuários</div>
|
||||||
|
<div class="stat-value text-primary">{usuariosQuery.data.length}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-gradient-to-br from-success/20 to-success/10 shadow-lg rounded-xl border border-success/30">
|
||||||
|
<div class="stat-title">Ativos</div>
|
||||||
|
<div class="stat-value text-success">
|
||||||
|
{usuariosQuery.data.filter((u: any) => u.ativo).length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-gradient-to-br from-error/20 to-error/10 shadow-lg rounded-xl border border-error/30">
|
||||||
|
<div class="stat-title">Inativos</div>
|
||||||
|
<div class="stat-value text-error">
|
||||||
|
{usuariosQuery.data.filter((u: any) => !u.ativo).length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Filtros -->
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-6 border border-base-300">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-lg mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="filtro-nome-usuario">
|
||||||
|
<span class="label-text font-semibold">Nome</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filtro-nome-usuario"
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por nome..."
|
||||||
|
class="input input-bordered input-primary w-full"
|
||||||
|
bind:value={filtroNomeUsuario}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="filtro-matricula-usuario">
|
||||||
|
<span class="label-text font-semibold">Matrícula</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filtro-matricula-usuario"
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por matrícula..."
|
||||||
|
class="input input-bordered input-primary w-full"
|
||||||
|
bind:value={filtroMatriculaUsuario}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="filtro-role-usuario">
|
||||||
|
<span class="label-text font-semibold">Função</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="filtro-role-usuario"
|
||||||
|
class="select select-bordered select-primary w-full"
|
||||||
|
bind:value={filtroRoleUsuario}
|
||||||
|
>
|
||||||
|
<option value="todos">Todos</option>
|
||||||
|
{#if rolesQuery.data}
|
||||||
|
{#each rolesQuery.data as role}
|
||||||
|
<option value={role.nome}>{role.nome}</option>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="filtro-status-usuario">
|
||||||
|
<span class="label-text font-semibold">Status</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="filtro-status-usuario"
|
||||||
|
class="select select-bordered select-primary w-full"
|
||||||
|
bind:value={filtroStatusUsuario}
|
||||||
|
>
|
||||||
|
<option value="todos">Todos</option>
|
||||||
|
<option value="ativo">Ativo</option>
|
||||||
|
<option value="inativo">Inativo</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabela de Usuários -->
|
||||||
|
<div class="card bg-base-100 shadow-xl border border-base-300">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{#if usuariosQuery.isLoading}
|
||||||
|
<div class="flex justify-center items-center p-12">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
{:else if usuariosQuery.error}
|
||||||
|
<div class="alert alert-error m-4">
|
||||||
|
<span>Erro ao carregar usuários: {usuariosQuery.error.message}</span>
|
||||||
|
</div>
|
||||||
|
{:else if filteredUsuarios().length === 0}
|
||||||
|
<div class="text-center py-12 text-base-content/70">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto mb-4 opacity-50" 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>
|
||||||
|
<p class="text-lg font-semibold">Nenhum usuário encontrado</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-zebra w-full">
|
||||||
|
<thead class="bg-base-200">
|
||||||
|
<tr>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Nome</th>
|
||||||
|
<th>Matrícula</th>
|
||||||
|
<th>E-mail</th>
|
||||||
|
<th>Função</th>
|
||||||
|
<th>Nível</th>
|
||||||
|
<th>Último Acesso</th>
|
||||||
|
<th>Ações</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredUsuarios() as usuario (usuario._id)}
|
||||||
|
<tr class="hover">
|
||||||
|
<td>
|
||||||
|
{#if usuario.ativo}
|
||||||
|
<span class="badge badge-success badge-lg">🟢 Ativo</span>
|
||||||
|
{:else}
|
||||||
|
<span class="badge badge-error badge-lg">🔴 Inativo</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="font-semibold">{usuario.nome}</td>
|
||||||
|
<td><code class="bg-base-200 px-2 py-1 rounded">{usuario.matricula}</code></td>
|
||||||
|
<td class="text-sm">{usuario.email}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-primary">{usuario.role.nome}</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<span class="badge badge-outline">{usuario.role.nivel}</span>
|
||||||
|
</td>
|
||||||
|
<td class="font-mono text-sm">
|
||||||
|
{usuario.ultimoAcesso ? formatarData(usuario.ultimoAcesso) : "Nunca"}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="dropdown dropdown-end">
|
||||||
|
<button type="button" tabindex="0" class="btn btn-ghost btn-sm">
|
||||||
|
<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 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>
|
||||||
|
</button>
|
||||||
|
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow-xl bg-base-100 rounded-box w-52">
|
||||||
|
{#if usuario.ativo}
|
||||||
|
<li><button type="button" onclick={() => abrirModalUsuario(usuario._id, "desativar")} class="text-error">Desativar</button></li>
|
||||||
|
{:else}
|
||||||
|
<li><button type="button" onclick={() => abrirModalUsuario(usuario._id, "ativar")} class="text-success">Ativar</button></li>
|
||||||
|
{/if}
|
||||||
|
<li><button type="button" onclick={() => abrirModalUsuario(usuario._id, "alterar_role")} class="text-primary">Alterar Função/Nível</button></li>
|
||||||
|
<li><button type="button" onclick={() => abrirModalUsuario(usuario._id, "resetar")}>Resetar Senha</button></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ABA 3: LOGS -->
|
||||||
|
{#if abaAtiva === "logs"}
|
||||||
|
<!-- Estatísticas -->
|
||||||
|
{#if logsQuery.data}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-5 gap-4 mb-6">
|
||||||
|
<div class="stat bg-gradient-to-br from-primary/20 to-primary/10 shadow-lg rounded-xl border border-primary/30">
|
||||||
|
<div class="stat-title">Total</div>
|
||||||
|
<div class="stat-value text-primary text-3xl">{logsQuery.data.length}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-gradient-to-br from-success/20 to-success/10 shadow-lg rounded-xl border border-success/30">
|
||||||
|
<div class="stat-title">Logins</div>
|
||||||
|
<div class="stat-value text-success text-3xl">
|
||||||
|
{logsQuery.data.filter((log: any) => log.tipo === "login").length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-gradient-to-br from-info/20 to-info/10 shadow-lg rounded-xl border border-info/30">
|
||||||
|
<div class="stat-title">Logouts</div>
|
||||||
|
<div class="stat-value text-info text-3xl">
|
||||||
|
{logsQuery.data.filter((log: any) => log.tipo === "logout").length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-gradient-to-br from-error/20 to-error/10 shadow-lg rounded-xl border border-error/30">
|
||||||
|
<div class="stat-title">Negados</div>
|
||||||
|
<div class="stat-value text-error text-3xl">
|
||||||
|
{logsQuery.data.filter((log: any) => log.tipo === "acesso_negado").length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat bg-gradient-to-br from-warning/20 to-warning/10 shadow-lg rounded-xl border border-warning/30">
|
||||||
|
<div class="stat-title">Outros</div>
|
||||||
|
<div class="stat-value text-warning text-3xl">
|
||||||
|
{logsQuery.data.filter((log: any) => !["login", "logout", "acesso_negado"].includes(log.tipo)).length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Filtros e Ações -->
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-6 border border-base-300">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="card-title text-lg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-error btn-sm"
|
||||||
|
onclick={() => (modalLimparLogsAberto = true)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
Limpar Histórico
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="filtro-tipo-log">
|
||||||
|
<span class="label-text font-semibold">Tipo de Evento</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="filtro-tipo-log"
|
||||||
|
class="select select-bordered select-primary w-full"
|
||||||
|
bind:value={filtroTipoLog}
|
||||||
|
>
|
||||||
|
<option value="todos">Todos</option>
|
||||||
|
<option value="login">Login</option>
|
||||||
|
<option value="logout">Logout</option>
|
||||||
|
<option value="acesso_negado">Acesso Negado</option>
|
||||||
|
<option value="senha_alterada">Senha Alterada</option>
|
||||||
|
<option value="sessao_expirada">Sessão Expirada</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label" for="filtro-usuario-log">
|
||||||
|
<span class="label-text font-semibold">Usuário</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="filtro-usuario-log"
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar por usuário..."
|
||||||
|
class="input input-bordered input-primary w-full"
|
||||||
|
bind:value={filtroUsuarioLog}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabela de Logs -->
|
||||||
|
<div class="card bg-base-100 shadow-xl border border-base-300">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
{#if logsQuery.isLoading}
|
||||||
|
<div class="flex justify-center items-center p-12">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
{:else if logsQuery.error}
|
||||||
|
<div class="alert alert-error m-4">
|
||||||
|
<span>Erro ao carregar logs: {logsQuery.error.message}</span>
|
||||||
|
</div>
|
||||||
|
{:else if filteredLogs().length === 0}
|
||||||
|
<div class="text-center py-12 text-base-content/70">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto mb-4 opacity-50" 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>
|
||||||
|
<p class="text-lg font-semibold">Nenhum log encontrado</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-zebra w-full">
|
||||||
|
<thead class="bg-base-200">
|
||||||
|
<tr>
|
||||||
|
<th>Data/Hora</th>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Usuário</th>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>Detalhes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredLogs() as log (log._id)}
|
||||||
|
<tr class="hover">
|
||||||
|
<td class="font-mono text-sm">{formatarData(log.timestamp)}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-lg">
|
||||||
|
{getTipoLogIcon(log.tipo)} {getTipoLogLabel(log.tipo)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="font-semibold">
|
||||||
|
{log.usuario ? log.usuario.nome : "Sistema"}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono text-sm">
|
||||||
|
{log.ipAddress || "-"}
|
||||||
|
</td>
|
||||||
|
<td class="text-sm text-base-content/70">
|
||||||
|
{log.detalhes || "-"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Modal Solicitação -->
|
||||||
|
{#if modalSolicitacaoAberto}
|
||||||
|
<dialog class="modal modal-open">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg mb-4">
|
||||||
|
{acaoModalSolicitacao === "aprovar" ? "Aprovar Solicitação" : "Rejeitar Solicitação"}
|
||||||
|
</h3>
|
||||||
|
<p class="mb-4">
|
||||||
|
{acaoModalSolicitacao === "aprovar"
|
||||||
|
? "Tem certeza que deseja aprovar esta solicitação de acesso?"
|
||||||
|
: "Tem certeza que deseja rejeitar esta solicitação de acesso?"}
|
||||||
|
</p>
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label" for="observacoes-solicitacao">
|
||||||
|
<span class="label-text">Observações (opcional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="observacoes-solicitacao"
|
||||||
|
class="textarea textarea-bordered h-24"
|
||||||
|
placeholder="Adicione observações sobre esta decisão..."
|
||||||
|
bind:value={observacoesSolicitacao}
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button class="btn btn-ghost" onclick={fecharModalSolicitacao}>Cancelar</button>
|
||||||
|
<button
|
||||||
|
class="btn {acaoModalSolicitacao === 'aprovar' ? 'btn-success' : 'btn-error'}"
|
||||||
|
onclick={confirmarAcaoSolicitacao}
|
||||||
|
>
|
||||||
|
{acaoModalSolicitacao === "aprovar" ? "Aprovar" : "Rejeitar"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop" onclick={fecharModalSolicitacao}>
|
||||||
|
<button type="button">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Modal Usuário -->
|
||||||
|
{#if modalUsuarioAberto}
|
||||||
|
<dialog class="modal modal-open">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg mb-4">
|
||||||
|
{acaoModalUsuario === "ativar" ? "Ativar Usuário" :
|
||||||
|
acaoModalUsuario === "desativar" ? "Desativar Usuário" :
|
||||||
|
acaoModalUsuario === "alterar_role" ? "Alterar Função/Nível" : "Resetar Senha"}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{#if acaoModalUsuario === "alterar_role"}
|
||||||
|
<p class="mb-4">Selecione a nova função/nível para este usuário:</p>
|
||||||
|
<div class="form-control mb-4">
|
||||||
|
<label class="label" for="select-role">
|
||||||
|
<span class="label-text font-semibold">Função</span>
|
||||||
|
</label>
|
||||||
|
{#if rolesQuery.isLoading}
|
||||||
|
<div class="flex justify-center p-4">
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
</div>
|
||||||
|
{:else if rolesQuery.data && rolesQuery.data.length > 0}
|
||||||
|
<select
|
||||||
|
id="select-role"
|
||||||
|
class="select select-bordered select-primary w-full"
|
||||||
|
bind:value={novaRoleId}
|
||||||
|
>
|
||||||
|
{#each rolesQuery.data as role}
|
||||||
|
<option value={role._id}>
|
||||||
|
{role.nome} (Nível {role.nivel}){#if role.setor} - {role.setor}{/if}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text-alt text-base-content/60">
|
||||||
|
Quanto menor o nível, maior o acesso (0 = Admin)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{:else}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<span>Nenhuma função disponível. Verifique se as roles foram criadas no banco de dados.</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="mb-4">
|
||||||
|
{acaoModalUsuario === "ativar" ? "Tem certeza que deseja ativar este usuário?" :
|
||||||
|
acaoModalUsuario === "desativar" ? "Tem certeza que deseja desativar este usuário?" :
|
||||||
|
"Tem certeza que deseja resetar a senha deste usuário? A nova senha será 'Mudar@123'."}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button class="btn btn-ghost" onclick={fecharModalUsuario}>Cancelar</button>
|
||||||
|
<button
|
||||||
|
class="btn {acaoModalUsuario === 'ativar' ? 'btn-success' : acaoModalUsuario === 'alterar_role' ? 'btn-primary' : 'btn-error'}"
|
||||||
|
onclick={confirmarAcaoUsuario}
|
||||||
|
disabled={acaoModalUsuario === 'alterar_role' && !novaRoleId}
|
||||||
|
>
|
||||||
|
Confirmar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop" onclick={fecharModalUsuario}>
|
||||||
|
<button type="button">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Modal Limpar Logs -->
|
||||||
|
{#if modalLimparLogsAberto}
|
||||||
|
<dialog class="modal modal-open">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg mb-4 text-error">⚠️ Limpar Histórico de Logs</h3>
|
||||||
|
<p class="mb-4">
|
||||||
|
<strong>ATENÇÃO:</strong> Esta ação irá remover TODOS os logs de acesso do sistema.
|
||||||
|
Esta ação é <strong>IRREVERSÍVEL</strong>.
|
||||||
|
</p>
|
||||||
|
<p class="mb-4 text-base-content/70">
|
||||||
|
Tem certeza que deseja continuar?
|
||||||
|
</p>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button class="btn btn-ghost" onclick={() => (modalLimparLogsAberto = false)}>Cancelar</button>
|
||||||
|
<button class="btn btn-error" onclick={limparLogs}>
|
||||||
|
<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>
|
||||||
|
Sim, Limpar Tudo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop" onclick={() => (modalLimparLogsAberto = false)}>
|
||||||
|
<button type="button">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</ProtectedRoute>
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useQuery, useConvexClient } from "convex-svelte";
|
||||||
|
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||||
|
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
|
||||||
|
// Buscar matriz de permissões
|
||||||
|
const matrizQuery = useQuery(api.menuPermissoes.obterMatrizPermissoes, {});
|
||||||
|
|
||||||
|
let salvando = $state(false);
|
||||||
|
let mensagem = $state<{ tipo: "success" | "error"; texto: string } | null>(null);
|
||||||
|
|
||||||
|
async function atualizarPermissao(
|
||||||
|
roleId: Id<"roles">,
|
||||||
|
menuPath: string,
|
||||||
|
campo: "podeAcessar" | "podeConsultar" | "podeGravar",
|
||||||
|
valor: boolean
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
salvando = true;
|
||||||
|
|
||||||
|
// Se está marcando podeGravar, deve marcar podeConsultar e podeAcessar também
|
||||||
|
let podeAcessar = valor;
|
||||||
|
let podeConsultar = valor;
|
||||||
|
let podeGravar = campo === "podeGravar" ? valor : false;
|
||||||
|
|
||||||
|
if (campo === "podeConsultar") {
|
||||||
|
podeConsultar = valor;
|
||||||
|
podeGravar = false; // Desmarcar gravar se desmarcou consultar
|
||||||
|
}
|
||||||
|
|
||||||
|
if (campo === "podeAcessar") {
|
||||||
|
podeAcessar = valor;
|
||||||
|
if (!valor) {
|
||||||
|
podeConsultar = false;
|
||||||
|
podeGravar = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar a permissão atual para aplicar lógica correta
|
||||||
|
const roleData = matrizQuery.data?.find((r) => r.role._id === roleId);
|
||||||
|
const permissaoAtual = roleData?.permissoes.find((p) => p.menuPath === menuPath);
|
||||||
|
|
||||||
|
if (permissaoAtual) {
|
||||||
|
// Aplicar lógica de dependências
|
||||||
|
if (campo === "podeGravar" && valor) {
|
||||||
|
podeAcessar = true;
|
||||||
|
podeConsultar = true;
|
||||||
|
podeGravar = true;
|
||||||
|
} else if (campo === "podeConsultar" && valor) {
|
||||||
|
podeAcessar = true;
|
||||||
|
podeConsultar = true;
|
||||||
|
podeGravar = permissaoAtual.podeGravar;
|
||||||
|
} else if (campo === "podeAcessar" && !valor) {
|
||||||
|
podeAcessar = false;
|
||||||
|
podeConsultar = false;
|
||||||
|
podeGravar = false;
|
||||||
|
} else if (campo === "podeConsultar" && !valor) {
|
||||||
|
podeAcessar = permissaoAtual.podeAcessar;
|
||||||
|
podeConsultar = false;
|
||||||
|
podeGravar = false;
|
||||||
|
} else if (campo === "podeGravar" && !valor) {
|
||||||
|
podeAcessar = permissaoAtual.podeAcessar;
|
||||||
|
podeConsultar = permissaoAtual.podeConsultar;
|
||||||
|
podeGravar = false;
|
||||||
|
} else {
|
||||||
|
podeAcessar = permissaoAtual.podeAcessar;
|
||||||
|
podeConsultar = permissaoAtual.podeConsultar;
|
||||||
|
podeGravar = permissaoAtual.podeGravar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.mutation(api.menuPermissoes.atualizarPermissao, {
|
||||||
|
roleId,
|
||||||
|
menuPath,
|
||||||
|
podeAcessar,
|
||||||
|
podeConsultar,
|
||||||
|
podeGravar,
|
||||||
|
});
|
||||||
|
|
||||||
|
mensagem = { tipo: "success", texto: "Permissão atualizada com sucesso!" };
|
||||||
|
setTimeout(() => {
|
||||||
|
mensagem = null;
|
||||||
|
}, 3000);
|
||||||
|
} catch (e: any) {
|
||||||
|
mensagem = { tipo: "error", texto: e.message || "Erro ao atualizar permissão" };
|
||||||
|
} finally {
|
||||||
|
salvando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inicializarPermissoes(roleId: Id<"roles">) {
|
||||||
|
try {
|
||||||
|
salvando = true;
|
||||||
|
await client.mutation(api.menuPermissoes.inicializarPermissoesRole, { roleId });
|
||||||
|
mensagem = { tipo: "success", texto: "Permissões inicializadas!" };
|
||||||
|
setTimeout(() => {
|
||||||
|
mensagem = null;
|
||||||
|
}, 3000);
|
||||||
|
} catch (e: any) {
|
||||||
|
mensagem = { tipo: "error", texto: e.message || "Erro ao inicializar permissões" };
|
||||||
|
} finally {
|
||||||
|
salvando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProtectedRoute allowedRoles={["admin", "ti"]} maxLevel={1}>
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="text-sm breadcrumbs mb-4">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="/" class="text-primary hover:text-primary-focus">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||||
|
</svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/ti" class="text-primary hover:text-primary-focus">TI</a>
|
||||||
|
</li>
|
||||||
|
<li class="font-semibold">Gerenciar Permissões</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<div class="p-3 bg-primary/10 rounded-xl">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h1 class="text-3xl font-bold text-base-content">Gerenciar Permissões de Acesso</h1>
|
||||||
|
<p class="text-base-content/60 mt-1">Configure as permissões de acesso aos menus do sistema por função</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost gap-2" onclick={() => goto("/ti")}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
Voltar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alertas -->
|
||||||
|
{#if mensagem}
|
||||||
|
<div class="alert mb-6 shadow-lg" class:alert-success={mensagem.tipo === "success"} class:alert-error={mensagem.tipo === "error"}>
|
||||||
|
{#if mensagem.tipo === "success"}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<span class="font-semibold">{mensagem.texto}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Informações sobre o sistema de permissões -->
|
||||||
|
<div class="alert alert-info mb-6">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Matriz de Permissões -->
|
||||||
|
{#if matrizQuery.isLoading}
|
||||||
|
<div class="flex justify-center items-center py-12">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
{:else if matrizQuery.error}
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>Erro ao carregar permissões: {matrizQuery.error.message}</span>
|
||||||
|
</div>
|
||||||
|
{:else if matrizQuery.data}
|
||||||
|
{#each matrizQuery.data 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>
|
||||||
|
{#if roleData.role.nivel <= 1}
|
||||||
|
<div class="badge badge-success">Acesso Total</div>
|
||||||
|
{/if}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-base-content/60 mt-1">{roleData.role.descricao}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if roleData.role.nivel > 1}
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline btn-primary"
|
||||||
|
onclick={() => inicializarPermissoes(roleData.role._id)}
|
||||||
|
disabled={salvando}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
Inicializar Permissões
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if roleData.role.nivel <= 1}
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>Esta função possui acesso total ao sistema automaticamente.</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-zebra table-sm">
|
||||||
|
<thead class="bg-base-200">
|
||||||
|
<tr>
|
||||||
|
<th class="w-1/3">Menu</th>
|
||||||
|
<th class="text-center">
|
||||||
|
<div class="flex items-center justify-center 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="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>
|
||||||
|
Acessar
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="text-center">
|
||||||
|
<div class="flex items-center justify-center 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
Consultar
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="text-center">
|
||||||
|
<div class="flex items-center justify-center 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="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>
|
||||||
|
Gravar
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each roleData.permissoes as permissao}
|
||||||
|
<tr class="hover">
|
||||||
|
<td>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="font-semibold">{permissao.menuNome}</span>
|
||||||
|
<span class="text-xs text-base-content/60">{permissao.menuPath}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-primary"
|
||||||
|
checked={permissao.podeAcessar}
|
||||||
|
disabled={salvando}
|
||||||
|
onchange={(e) =>
|
||||||
|
atualizarPermissao(
|
||||||
|
roleData.role._id,
|
||||||
|
permissao.menuPath,
|
||||||
|
"podeAcessar",
|
||||||
|
e.currentTarget.checked
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-info"
|
||||||
|
checked={permissao.podeConsultar}
|
||||||
|
disabled={salvando || !permissao.podeAcessar}
|
||||||
|
onchange={(e) =>
|
||||||
|
atualizarPermissao(
|
||||||
|
roleData.role._id,
|
||||||
|
permissao.menuPath,
|
||||||
|
"podeConsultar",
|
||||||
|
e.currentTarget.checked
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-success"
|
||||||
|
checked={permissao.podeGravar}
|
||||||
|
disabled={salvando || !permissao.podeConsultar}
|
||||||
|
onchange={(e) =>
|
||||||
|
atualizarPermissao(
|
||||||
|
roleData.role._id,
|
||||||
|
permissao.menuPath,
|
||||||
|
"podeGravar",
|
||||||
|
e.currentTarget.checked
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</ProtectedRoute>
|
||||||
|
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useQuery, useConvexClient } from "convex-svelte";
|
||||||
|
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||||
|
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
|
||||||
|
let matriculaBusca = $state("");
|
||||||
|
let usuarioEncontrado = $state<any>(null);
|
||||||
|
let buscando = $state(false);
|
||||||
|
let salvando = $state(false);
|
||||||
|
let mensagem = $state<{ tipo: "success" | "error"; texto: string } | null>(null);
|
||||||
|
|
||||||
|
// Buscar permissões personalizadas do usuário
|
||||||
|
const permissoesQuery = $derived(
|
||||||
|
usuarioEncontrado
|
||||||
|
? useQuery(api.menuPermissoes.listarPermissoesPersonalizadas, {
|
||||||
|
matricula: usuarioEncontrado.matricula,
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Buscar menus disponíveis
|
||||||
|
const menusQuery = useQuery(api.menuPermissoes.listarMenus, {});
|
||||||
|
|
||||||
|
async function buscarUsuario() {
|
||||||
|
if (!matriculaBusca.trim()) {
|
||||||
|
mensagem = { tipo: "error", texto: "Digite uma matrícula para buscar" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
buscando = true;
|
||||||
|
const usuario = await client.query(api.menuPermissoes.buscarUsuarioPorMatricula, {
|
||||||
|
matricula: matriculaBusca.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (usuario) {
|
||||||
|
usuarioEncontrado = usuario;
|
||||||
|
mensagem = null;
|
||||||
|
} else {
|
||||||
|
usuarioEncontrado = null;
|
||||||
|
mensagem = { tipo: "error", texto: "Usuário não encontrado com esta matrícula" };
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
mensagem = { tipo: "error", texto: e.message || "Erro ao buscar usuário" };
|
||||||
|
} finally {
|
||||||
|
buscando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function atualizarPermissao(
|
||||||
|
menuPath: string,
|
||||||
|
campo: "podeAcessar" | "podeConsultar" | "podeGravar",
|
||||||
|
valor: boolean
|
||||||
|
) {
|
||||||
|
if (!usuarioEncontrado) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
salvando = true;
|
||||||
|
|
||||||
|
// Obter permissão atual do menu
|
||||||
|
const permissaoAtual = permissoesQuery?.data?.find((p) => p.menuPath === menuPath);
|
||||||
|
|
||||||
|
let podeAcessar = valor;
|
||||||
|
let podeConsultar = false;
|
||||||
|
let podeGravar = false;
|
||||||
|
|
||||||
|
// Aplicar lógica de dependências
|
||||||
|
if (campo === "podeGravar" && valor) {
|
||||||
|
podeAcessar = true;
|
||||||
|
podeConsultar = true;
|
||||||
|
podeGravar = true;
|
||||||
|
} else if (campo === "podeConsultar" && valor) {
|
||||||
|
podeAcessar = true;
|
||||||
|
podeConsultar = true;
|
||||||
|
podeGravar = permissaoAtual?.podeGravar || false;
|
||||||
|
} else if (campo === "podeAcessar" && !valor) {
|
||||||
|
podeAcessar = false;
|
||||||
|
podeConsultar = false;
|
||||||
|
podeGravar = false;
|
||||||
|
} else if (campo === "podeConsultar" && !valor) {
|
||||||
|
podeAcessar = permissaoAtual?.podeAcessar !== undefined ? permissaoAtual.podeAcessar : false;
|
||||||
|
podeConsultar = false;
|
||||||
|
podeGravar = false;
|
||||||
|
} else if (campo === "podeGravar" && !valor) {
|
||||||
|
podeAcessar = permissaoAtual?.podeAcessar !== undefined ? permissaoAtual.podeAcessar : false;
|
||||||
|
podeConsultar = permissaoAtual?.podeConsultar !== undefined ? permissaoAtual.podeConsultar : false;
|
||||||
|
podeGravar = false;
|
||||||
|
} else if (permissaoAtual) {
|
||||||
|
podeAcessar = permissaoAtual.podeAcessar;
|
||||||
|
podeConsultar = permissaoAtual.podeConsultar;
|
||||||
|
podeGravar = permissaoAtual.podeGravar;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.mutation(api.menuPermissoes.atualizarPermissaoPersonalizada, {
|
||||||
|
matricula: usuarioEncontrado.matricula,
|
||||||
|
menuPath,
|
||||||
|
podeAcessar,
|
||||||
|
podeConsultar,
|
||||||
|
podeGravar,
|
||||||
|
});
|
||||||
|
|
||||||
|
mensagem = { tipo: "success", texto: "Permissão personalizada atualizada!" };
|
||||||
|
setTimeout(() => {
|
||||||
|
mensagem = null;
|
||||||
|
}, 3000);
|
||||||
|
} catch (e: any) {
|
||||||
|
mensagem = { tipo: "error", texto: e.message || "Erro ao atualizar permissão" };
|
||||||
|
} finally {
|
||||||
|
salvando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function limparBusca() {
|
||||||
|
matriculaBusca = "";
|
||||||
|
usuarioEncontrado = null;
|
||||||
|
mensagem = null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProtectedRoute allowedRoles={["admin", "ti"]} maxLevel={1}>
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="text-sm breadcrumbs mb-4">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="/" class="text-primary hover:text-primary-focus">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||||
|
</svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/ti" class="text-primary hover:text-primary-focus">TI</a>
|
||||||
|
</li>
|
||||||
|
<li class="font-semibold">Personalizar Permissões</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h1 class="text-3xl font-bold text-base-content">Personalizar Permissões por Matrícula</h1>
|
||||||
|
<p class="text-base-content/60 mt-1">Configure permissões específicas para usuários individuais</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost gap-2" onclick={() => goto("/ti")}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
Voltar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alertas -->
|
||||||
|
{#if mensagem}
|
||||||
|
<div class="alert mb-6 shadow-lg" class:alert-success={mensagem.tipo === "success"} class:alert-error={mensagem.tipo === "error"}>
|
||||||
|
{#if mensagem.tipo === "success"}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
<span class="font-semibold">{mensagem.texto}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Card de Busca -->
|
||||||
|
<div class="card bg-base-100 shadow-xl mb-6">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">Buscar Usuário</h2>
|
||||||
|
<p class="text-sm text-base-content/60">Digite a matrícula do usuário para personalizar suas permissões</p>
|
||||||
|
|
||||||
|
<div class="flex gap-4 mt-4">
|
||||||
|
<div class="form-control flex-1">
|
||||||
|
<label class="label" for="matricula-busca">
|
||||||
|
<span class="label-text font-semibold">Matrícula</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="matricula-busca"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered input-primary w-full"
|
||||||
|
placeholder="Digite a matrícula..."
|
||||||
|
bind:value={matriculaBusca}
|
||||||
|
disabled={buscando}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && buscarUsuario()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-end gap-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
onclick={buscarUsuario}
|
||||||
|
disabled={buscando || !matriculaBusca.trim()}
|
||||||
|
>
|
||||||
|
{#if buscando}
|
||||||
|
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
Buscar
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if usuarioEncontrado}
|
||||||
|
<button class="btn btn-ghost" onclick={limparBusca}>
|
||||||
|
<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>
|
||||||
|
Limpar
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Informações do Usuário -->
|
||||||
|
{#if usuarioEncontrado}
|
||||||
|
<div class="card bg-gradient-to-br from-info/10 to-info/5 shadow-xl mb-6 border-2 border-info/20">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="avatar placeholder">
|
||||||
|
<div class="bg-info text-info-content rounded-full w-16">
|
||||||
|
<span class="text-2xl font-bold">{usuarioEncontrado.nome.charAt(0)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-xl font-bold">{usuarioEncontrado.nome}</h3>
|
||||||
|
<div class="flex gap-4 mt-1 text-sm">
|
||||||
|
<span class="flex items-center 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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||||
|
</svg>
|
||||||
|
<strong>Matrícula:</strong> {usuarioEncontrado.matricula}
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center 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="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>
|
||||||
|
<strong>Email:</strong> {usuarioEncontrado.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="badge badge-primary badge-lg">
|
||||||
|
Nível {usuarioEncontrado.role.nivel}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm mt-1">{usuarioEncontrado.role.descricao}</p>
|
||||||
|
<div class="badge mt-2" class:badge-success={usuarioEncontrado.ativo} class:badge-error={!usuarioEncontrado.ativo}>
|
||||||
|
{usuarioEncontrado.ativo ? "Ativo" : "Inativo"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabela de Permissões -->
|
||||||
|
<div class="card bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title">Permissões Personalizadas</h2>
|
||||||
|
<div class="alert alert-info mb-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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm">
|
||||||
|
<strong>Permissões personalizadas sobrepõem as permissões da função.</strong><br />
|
||||||
|
Configure apenas os menus que deseja personalizar para este usuário.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if menusQuery.isLoading}
|
||||||
|
<div class="flex justify-center py-12">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
{:else if menusQuery.data}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-zebra table-sm">
|
||||||
|
<thead class="bg-base-200">
|
||||||
|
<tr>
|
||||||
|
<th class="w-1/3">Menu</th>
|
||||||
|
<th class="text-center">
|
||||||
|
<div class="flex items-center justify-center 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="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>
|
||||||
|
Acessar
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="text-center">
|
||||||
|
<div class="flex items-center justify-center 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
Consultar
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="text-center">
|
||||||
|
<div class="flex items-center justify-center 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="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>
|
||||||
|
Gravar
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th class="text-center">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each menusQuery.data as menu}
|
||||||
|
{@const permissao = permissoesQuery?.data?.find((p) => p.menuPath === menu.path)}
|
||||||
|
<tr class="hover">
|
||||||
|
<td>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="font-semibold">{menu.nome}</span>
|
||||||
|
<span class="text-xs text-base-content/60">{menu.path}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-primary"
|
||||||
|
checked={permissao?.podeAcessar || false}
|
||||||
|
disabled={salvando}
|
||||||
|
onchange={(e) =>
|
||||||
|
atualizarPermissao(menu.path, "podeAcessar", e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-info"
|
||||||
|
checked={permissao?.podeConsultar || false}
|
||||||
|
disabled={salvando || !permissao?.podeAcessar}
|
||||||
|
onchange={(e) =>
|
||||||
|
atualizarPermissao(menu.path, "podeConsultar", e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-success"
|
||||||
|
checked={permissao?.podeGravar || false}
|
||||||
|
disabled={salvando || !permissao?.podeConsultar}
|
||||||
|
onchange={(e) =>
|
||||||
|
atualizarPermissao(menu.path, "podeGravar", e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
{#if permissao}
|
||||||
|
<div class="badge badge-warning badge-sm">Personalizado</div>
|
||||||
|
{:else}
|
||||||
|
<div class="badge badge-ghost badge-sm">Padrão da Função</div>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</ProtectedRoute>
|
||||||
|
|
||||||
22
packages/backend/convex/_generated/api.d.ts
vendored
22
packages/backend/convex/_generated/api.d.ts
vendored
@@ -8,16 +8,27 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type * as autenticacao from "../autenticacao.js";
|
||||||
|
import type * as auth_utils from "../auth/utils.js";
|
||||||
import type * as auth from "../auth.js";
|
import type * as auth from "../auth.js";
|
||||||
import type * as betterAuth__generated_api from "../betterAuth/_generated/api.js";
|
import type * as betterAuth__generated_api from "../betterAuth/_generated/api.js";
|
||||||
import type * as betterAuth__generated_server from "../betterAuth/_generated/server.js";
|
import type * as betterAuth__generated_server from "../betterAuth/_generated/server.js";
|
||||||
import type * as betterAuth_adapter from "../betterAuth/adapter.js";
|
import type * as betterAuth_adapter from "../betterAuth/adapter.js";
|
||||||
import type * as betterAuth_auth from "../betterAuth/auth.js";
|
import type * as betterAuth_auth from "../betterAuth/auth.js";
|
||||||
|
import type * as dashboard from "../dashboard.js";
|
||||||
import type * as funcionarios from "../funcionarios.js";
|
import type * as funcionarios from "../funcionarios.js";
|
||||||
import type * as healthCheck from "../healthCheck.js";
|
import type * as healthCheck from "../healthCheck.js";
|
||||||
import type * as http from "../http.js";
|
import type * as http from "../http.js";
|
||||||
|
import type * as inicializarPermissoes from "../inicializarPermissoes.js";
|
||||||
|
import type * as logsAcesso from "../logsAcesso.js";
|
||||||
|
import type * as menuPermissoes from "../menuPermissoes.js";
|
||||||
|
import type * as monitoramento from "../monitoramento.js";
|
||||||
|
import type * as roles from "../roles.js";
|
||||||
|
import type * as seed from "../seed.js";
|
||||||
import type * as simbolos from "../simbolos.js";
|
import type * as simbolos from "../simbolos.js";
|
||||||
|
import type * as solicitacoesAcesso from "../solicitacoesAcesso.js";
|
||||||
import type * as todos from "../todos.js";
|
import type * as todos from "../todos.js";
|
||||||
|
import type * as usuarios from "../usuarios.js";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ApiFromModules,
|
ApiFromModules,
|
||||||
@@ -34,16 +45,27 @@ import type {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
declare const fullApi: ApiFromModules<{
|
declare const fullApi: ApiFromModules<{
|
||||||
|
autenticacao: typeof autenticacao;
|
||||||
|
"auth/utils": typeof auth_utils;
|
||||||
auth: typeof auth;
|
auth: typeof auth;
|
||||||
"betterAuth/_generated/api": typeof betterAuth__generated_api;
|
"betterAuth/_generated/api": typeof betterAuth__generated_api;
|
||||||
"betterAuth/_generated/server": typeof betterAuth__generated_server;
|
"betterAuth/_generated/server": typeof betterAuth__generated_server;
|
||||||
"betterAuth/adapter": typeof betterAuth_adapter;
|
"betterAuth/adapter": typeof betterAuth_adapter;
|
||||||
"betterAuth/auth": typeof betterAuth_auth;
|
"betterAuth/auth": typeof betterAuth_auth;
|
||||||
|
dashboard: typeof dashboard;
|
||||||
funcionarios: typeof funcionarios;
|
funcionarios: typeof funcionarios;
|
||||||
healthCheck: typeof healthCheck;
|
healthCheck: typeof healthCheck;
|
||||||
http: typeof http;
|
http: typeof http;
|
||||||
|
inicializarPermissoes: typeof inicializarPermissoes;
|
||||||
|
logsAcesso: typeof logsAcesso;
|
||||||
|
menuPermissoes: typeof menuPermissoes;
|
||||||
|
monitoramento: typeof monitoramento;
|
||||||
|
roles: typeof roles;
|
||||||
|
seed: typeof seed;
|
||||||
simbolos: typeof simbolos;
|
simbolos: typeof simbolos;
|
||||||
|
solicitacoesAcesso: typeof solicitacoesAcesso;
|
||||||
todos: typeof todos;
|
todos: typeof todos;
|
||||||
|
usuarios: typeof usuarios;
|
||||||
}>;
|
}>;
|
||||||
declare const fullApiWithMounts: typeof fullApi;
|
declare const fullApiWithMounts: typeof fullApi;
|
||||||
|
|
||||||
|
|||||||
381
packages/backend/convex/autenticacao.ts
Normal file
381
packages/backend/convex/autenticacao.ts
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import { v } from "convex/values";
|
||||||
|
import { mutation, query } from "./_generated/server";
|
||||||
|
import { hashPassword, verifyPassword, generateToken, validarMatricula, validarSenha } from "./auth/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login do usuário
|
||||||
|
*/
|
||||||
|
export const login = mutation({
|
||||||
|
args: {
|
||||||
|
matricula: v.string(),
|
||||||
|
senha: v.string(),
|
||||||
|
ipAddress: v.optional(v.string()),
|
||||||
|
userAgent: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
returns: v.union(
|
||||||
|
v.object({
|
||||||
|
sucesso: v.literal(true),
|
||||||
|
token: v.string(),
|
||||||
|
usuario: v.object({
|
||||||
|
_id: v.id("usuarios"),
|
||||||
|
matricula: v.string(),
|
||||||
|
nome: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
role: v.object({
|
||||||
|
_id: v.id("roles"),
|
||||||
|
nome: v.string(),
|
||||||
|
nivel: v.number(),
|
||||||
|
setor: v.optional(v.string()),
|
||||||
|
}),
|
||||||
|
primeiroAcesso: v.boolean(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
v.object({
|
||||||
|
sucesso: v.literal(false),
|
||||||
|
erro: v.string(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Validar matrícula
|
||||||
|
if (!validarMatricula(args.matricula)) {
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: "Matrícula inválida. Use apenas números.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar usuário
|
||||||
|
const usuario = await ctx.db
|
||||||
|
.query("usuarios")
|
||||||
|
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!usuario) {
|
||||||
|
// Log de tentativa de acesso negado
|
||||||
|
await ctx.db.insert("logsAcesso", {
|
||||||
|
usuarioId: "" as any, // Não temos ID
|
||||||
|
tipo: "acesso_negado",
|
||||||
|
ipAddress: args.ipAddress,
|
||||||
|
userAgent: args.userAgent,
|
||||||
|
detalhes: `Tentativa de login com matrícula inexistente: ${args.matricula}`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: "Matrícula ou senha incorreta.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se usuário está ativo
|
||||||
|
if (!usuario.ativo) {
|
||||||
|
await ctx.db.insert("logsAcesso", {
|
||||||
|
usuarioId: usuario._id,
|
||||||
|
tipo: "acesso_negado",
|
||||||
|
ipAddress: args.ipAddress,
|
||||||
|
userAgent: args.userAgent,
|
||||||
|
detalhes: "Tentativa de login com usuário inativo",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: "Usuário inativo. Entre em contato com o TI.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar senha
|
||||||
|
const senhaValida = await verifyPassword(args.senha, usuario.senhaHash);
|
||||||
|
|
||||||
|
if (!senhaValida) {
|
||||||
|
await ctx.db.insert("logsAcesso", {
|
||||||
|
usuarioId: usuario._id,
|
||||||
|
tipo: "acesso_negado",
|
||||||
|
ipAddress: args.ipAddress,
|
||||||
|
userAgent: args.userAgent,
|
||||||
|
detalhes: "Senha incorreta",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: "Matrícula ou senha incorreta.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar role do usuário
|
||||||
|
const role = await ctx.db.get(usuario.roleId);
|
||||||
|
if (!role) {
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: "Erro ao carregar permissões do usuário.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gerar token de sessão
|
||||||
|
const token = generateToken();
|
||||||
|
const agora = Date.now();
|
||||||
|
const expiraEm = agora + 8 * 60 * 60 * 1000; // 8 horas
|
||||||
|
|
||||||
|
// Criar sessão
|
||||||
|
await ctx.db.insert("sessoes", {
|
||||||
|
usuarioId: usuario._id,
|
||||||
|
token,
|
||||||
|
ipAddress: args.ipAddress,
|
||||||
|
userAgent: args.userAgent,
|
||||||
|
criadoEm: agora,
|
||||||
|
expiraEm,
|
||||||
|
ativo: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Atualizar último acesso
|
||||||
|
await ctx.db.patch(usuario._id, {
|
||||||
|
ultimoAcesso: agora,
|
||||||
|
atualizadoEm: agora,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log de login bem-sucedido
|
||||||
|
await ctx.db.insert("logsAcesso", {
|
||||||
|
usuarioId: usuario._id,
|
||||||
|
tipo: "login",
|
||||||
|
ipAddress: args.ipAddress,
|
||||||
|
userAgent: args.userAgent,
|
||||||
|
detalhes: "Login realizado com sucesso",
|
||||||
|
timestamp: agora,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
sucesso: true as const,
|
||||||
|
token,
|
||||||
|
usuario: {
|
||||||
|
_id: usuario._id,
|
||||||
|
matricula: usuario.matricula,
|
||||||
|
nome: usuario.nome,
|
||||||
|
email: usuario.email,
|
||||||
|
role: {
|
||||||
|
_id: role._id,
|
||||||
|
nome: role.nome,
|
||||||
|
nivel: role.nivel,
|
||||||
|
setor: role.setor,
|
||||||
|
},
|
||||||
|
primeiroAcesso: usuario.primeiroAcesso,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout do usuário
|
||||||
|
*/
|
||||||
|
export const logout = mutation({
|
||||||
|
args: {
|
||||||
|
token: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Buscar sessão
|
||||||
|
const sessao = await ctx.db
|
||||||
|
.query("sessoes")
|
||||||
|
.withIndex("by_token", (q) => q.eq("token", args.token))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (sessao) {
|
||||||
|
// Desativar sessão
|
||||||
|
await ctx.db.patch(sessao._id, {
|
||||||
|
ativo: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log de logout
|
||||||
|
await ctx.db.insert("logsAcesso", {
|
||||||
|
usuarioId: sessao.usuarioId,
|
||||||
|
tipo: "logout",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar se token é válido e retornar usuário
|
||||||
|
*/
|
||||||
|
export const verificarSessao = query({
|
||||||
|
args: {
|
||||||
|
token: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.union(
|
||||||
|
v.object({
|
||||||
|
valido: v.literal(true),
|
||||||
|
usuario: v.object({
|
||||||
|
_id: v.id("usuarios"),
|
||||||
|
matricula: v.string(),
|
||||||
|
nome: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
role: v.object({
|
||||||
|
_id: v.id("roles"),
|
||||||
|
nome: v.string(),
|
||||||
|
nivel: v.number(),
|
||||||
|
setor: v.optional(v.string()),
|
||||||
|
}),
|
||||||
|
primeiroAcesso: v.boolean(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
v.object({
|
||||||
|
valido: v.literal(false),
|
||||||
|
motivo: v.optional(v.string()),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Buscar sessão
|
||||||
|
const sessao = await ctx.db
|
||||||
|
.query("sessoes")
|
||||||
|
.withIndex("by_token", (q) => q.eq("token", args.token))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!sessao || !sessao.ativo) {
|
||||||
|
return { valido: false as const, motivo: "Sessão não encontrada ou inativa" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se sessão expirou
|
||||||
|
if (sessao.expiraEm < Date.now()) {
|
||||||
|
// Não podemos fazer patch/insert em uma query
|
||||||
|
// A expiração será tratada por uma mutation separada
|
||||||
|
return { valido: false as const, motivo: "Sessão expirada" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar usuário
|
||||||
|
const usuario = await ctx.db.get(sessao.usuarioId);
|
||||||
|
if (!usuario || !usuario.ativo) {
|
||||||
|
return { valido: false as const, motivo: "Usuário não encontrado ou inativo" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar role
|
||||||
|
const role = await ctx.db.get(usuario.roleId);
|
||||||
|
if (!role) {
|
||||||
|
return { valido: false as const, motivo: "Role não encontrada" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valido: true as const,
|
||||||
|
usuario: {
|
||||||
|
_id: usuario._id,
|
||||||
|
matricula: usuario.matricula,
|
||||||
|
nome: usuario.nome,
|
||||||
|
email: usuario.email,
|
||||||
|
role: {
|
||||||
|
_id: role._id,
|
||||||
|
nome: role.nome,
|
||||||
|
nivel: role.nivel,
|
||||||
|
setor: role.setor,
|
||||||
|
},
|
||||||
|
primeiroAcesso: usuario.primeiroAcesso,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpar sessões expiradas (chamada periodicamente)
|
||||||
|
*/
|
||||||
|
export const limparSessoesExpiradas = mutation({
|
||||||
|
args: {},
|
||||||
|
returns: v.object({
|
||||||
|
sessoesLimpas: v.number(),
|
||||||
|
}),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const agora = Date.now();
|
||||||
|
const sessoes = await ctx.db
|
||||||
|
.query("sessoes")
|
||||||
|
.withIndex("by_ativo", (q) => q.eq("ativo", true))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let sessoesLimpas = 0;
|
||||||
|
|
||||||
|
for (const sessao of sessoes) {
|
||||||
|
if (sessao.expiraEm < agora) {
|
||||||
|
await ctx.db.patch(sessao._id, { ativo: false });
|
||||||
|
|
||||||
|
await ctx.db.insert("logsAcesso", {
|
||||||
|
usuarioId: sessao.usuarioId,
|
||||||
|
tipo: "sessao_expirada",
|
||||||
|
timestamp: agora,
|
||||||
|
});
|
||||||
|
|
||||||
|
sessoesLimpas++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sessoesLimpas };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alterar senha (primeiro acesso ou reset)
|
||||||
|
*/
|
||||||
|
export const alterarSenha = mutation({
|
||||||
|
args: {
|
||||||
|
token: v.string(),
|
||||||
|
senhaAtual: v.optional(v.string()),
|
||||||
|
novaSenha: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.union(
|
||||||
|
v.object({ sucesso: v.literal(true) }),
|
||||||
|
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Verificar sessão
|
||||||
|
const sessao = await ctx.db
|
||||||
|
.query("sessoes")
|
||||||
|
.withIndex("by_token", (q) => q.eq("token", args.token))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!sessao || !sessao.ativo) {
|
||||||
|
return { sucesso: false as const, erro: "Sessão inválida" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const usuario = await ctx.db.get(sessao.usuarioId);
|
||||||
|
if (!usuario) {
|
||||||
|
return { sucesso: false as const, erro: "Usuário não encontrado" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se não for primeiro acesso, verificar senha atual
|
||||||
|
if (!usuario.primeiroAcesso && args.senhaAtual) {
|
||||||
|
const senhaAtualValida = await verifyPassword(
|
||||||
|
args.senhaAtual,
|
||||||
|
usuario.senhaHash
|
||||||
|
);
|
||||||
|
if (!senhaAtualValida) {
|
||||||
|
return { sucesso: false as const, erro: "Senha atual incorreta" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar nova senha
|
||||||
|
if (!validarSenha(args.novaSenha)) {
|
||||||
|
return {
|
||||||
|
sucesso: false as const,
|
||||||
|
erro: "Senha deve ter no mínimo 8 caracteres, incluindo letras, números e símbolos",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gerar hash da nova senha
|
||||||
|
const novoHash = await hashPassword(args.novaSenha);
|
||||||
|
|
||||||
|
// Atualizar senha
|
||||||
|
await ctx.db.patch(usuario._id, {
|
||||||
|
senhaHash: novoHash,
|
||||||
|
primeiroAcesso: false,
|
||||||
|
atualizadoEm: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log
|
||||||
|
await ctx.db.insert("logsAcesso", {
|
||||||
|
usuarioId: usuario._id,
|
||||||
|
tipo: "senha_alterada",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { sucesso: true as const };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
132
packages/backend/convex/auth/utils.ts
Normal file
132
packages/backend/convex/auth/utils.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Utilitários para autenticação e criptografia
|
||||||
|
* Usando Web Crypto API para criptografia segura
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gera um hash seguro de senha usando PBKDF2
|
||||||
|
*/
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(password);
|
||||||
|
|
||||||
|
// Gerar salt aleatório
|
||||||
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
|
||||||
|
// Importar a senha como chave
|
||||||
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
data,
|
||||||
|
"PBKDF2",
|
||||||
|
false,
|
||||||
|
["deriveBits"]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Derivar a chave usando PBKDF2
|
||||||
|
const derivedBits = await crypto.subtle.deriveBits(
|
||||||
|
{
|
||||||
|
name: "PBKDF2",
|
||||||
|
salt: salt,
|
||||||
|
iterations: 100000,
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
256
|
||||||
|
);
|
||||||
|
|
||||||
|
// Combinar salt + hash
|
||||||
|
const hashArray = new Uint8Array(derivedBits);
|
||||||
|
const combined = new Uint8Array(salt.length + hashArray.length);
|
||||||
|
combined.set(salt);
|
||||||
|
combined.set(hashArray, salt.length);
|
||||||
|
|
||||||
|
// Converter para base64
|
||||||
|
return btoa(String.fromCharCode(...combined));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica se uma senha corresponde ao hash
|
||||||
|
*/
|
||||||
|
export async function verifyPassword(
|
||||||
|
password: string,
|
||||||
|
hash: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Decodificar o hash de base64
|
||||||
|
const combined = Uint8Array.from(atob(hash), (c) => c.charCodeAt(0));
|
||||||
|
|
||||||
|
// Extrair salt e hash
|
||||||
|
const salt = combined.slice(0, 16);
|
||||||
|
const storedHash = combined.slice(16);
|
||||||
|
|
||||||
|
// Gerar hash da senha fornecida
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(password);
|
||||||
|
|
||||||
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
data,
|
||||||
|
"PBKDF2",
|
||||||
|
false,
|
||||||
|
["deriveBits"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const derivedBits = await crypto.subtle.deriveBits(
|
||||||
|
{
|
||||||
|
name: "PBKDF2",
|
||||||
|
salt: salt,
|
||||||
|
iterations: 100000,
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
256
|
||||||
|
);
|
||||||
|
|
||||||
|
const newHash = new Uint8Array(derivedBits);
|
||||||
|
|
||||||
|
// Comparar os hashes
|
||||||
|
if (newHash.length !== storedHash.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < newHash.length; i++) {
|
||||||
|
if (newHash[i] !== storedHash[i]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erro ao verificar senha:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gera um token aleatório seguro
|
||||||
|
*/
|
||||||
|
export function generateToken(): string {
|
||||||
|
const array = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
return btoa(String.fromCharCode(...array))
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida formato de matrícula (apenas números)
|
||||||
|
*/
|
||||||
|
export function validarMatricula(matricula: string): boolean {
|
||||||
|
return /^\d+$/.test(matricula) && matricula.length >= 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valida formato de senha (alfanuméricos e símbolos)
|
||||||
|
*/
|
||||||
|
export function validarSenha(senha: string): boolean {
|
||||||
|
// Mínimo 8 caracteres, pelo menos uma letra, um número e um símbolo
|
||||||
|
const regex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/;
|
||||||
|
return regex.test(senha);
|
||||||
|
}
|
||||||
|
|
||||||
178
packages/backend/convex/dashboard.ts
Normal file
178
packages/backend/convex/dashboard.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { query } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
|
||||||
|
// Obter estatísticas gerais do sistema
|
||||||
|
export const getStats = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.object({
|
||||||
|
totalFuncionarios: v.number(),
|
||||||
|
totalSimbolos: v.number(),
|
||||||
|
totalSolicitacoesAcesso: v.number(),
|
||||||
|
solicitacoesPendentes: v.number(),
|
||||||
|
funcionariosAtivos: v.number(),
|
||||||
|
funcionariosDesligados: v.number(),
|
||||||
|
cargoComissionado: v.number(),
|
||||||
|
funcaoGratificada: v.number(),
|
||||||
|
}),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
// Contar funcionários
|
||||||
|
const funcionarios = await ctx.db.query("funcionarios").collect();
|
||||||
|
const totalFuncionarios = funcionarios.length;
|
||||||
|
|
||||||
|
// Funcionários ativos (sem data de desligamento)
|
||||||
|
const funcionariosAtivos = funcionarios.filter(
|
||||||
|
(f) => !f.desligamentoData
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Funcionários desligados
|
||||||
|
const funcionariosDesligados = funcionarios.filter(
|
||||||
|
(f) => f.desligamentoData
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Contar por tipo de símbolo
|
||||||
|
const cargoComissionado = funcionarios.filter(
|
||||||
|
(f) => f.simboloTipo === "cargo_comissionado"
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const funcaoGratificada = funcionarios.filter(
|
||||||
|
(f) => f.simboloTipo === "funcao_gratificada"
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Contar símbolos
|
||||||
|
const simbolos = await ctx.db.query("simbolos").collect();
|
||||||
|
const totalSimbolos = simbolos.length;
|
||||||
|
|
||||||
|
// Contar solicitações de acesso
|
||||||
|
const solicitacoes = await ctx.db.query("solicitacoesAcesso").collect();
|
||||||
|
const totalSolicitacoesAcesso = solicitacoes.length;
|
||||||
|
|
||||||
|
const solicitacoesPendentes = solicitacoes.filter(
|
||||||
|
(s) => s.status === "pendente"
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalFuncionarios,
|
||||||
|
totalSimbolos,
|
||||||
|
totalSolicitacoesAcesso,
|
||||||
|
solicitacoesPendentes,
|
||||||
|
funcionariosAtivos,
|
||||||
|
funcionariosDesligados,
|
||||||
|
cargoComissionado,
|
||||||
|
funcaoGratificada,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obter atividades recentes (últimas 24 horas)
|
||||||
|
export const getRecentActivity = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.object({
|
||||||
|
funcionariosCadastrados24h: v.number(),
|
||||||
|
solicitacoesAcesso24h: v.number(),
|
||||||
|
simbolosCadastrados24h: v.number(),
|
||||||
|
}),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const last24h = now - 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Funcionários cadastrados nas últimas 24h
|
||||||
|
const funcionarios = await ctx.db.query("funcionarios").collect();
|
||||||
|
const funcionariosCadastrados24h = funcionarios.filter(
|
||||||
|
(f) => f._creationTime >= last24h
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Solicitações de acesso nas últimas 24h
|
||||||
|
const solicitacoes = await ctx.db.query("solicitacoesAcesso").collect();
|
||||||
|
const solicitacoesAcesso24h = solicitacoes.filter(
|
||||||
|
(s) => s.dataSolicitacao >= last24h
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Símbolos cadastrados nas últimas 24h
|
||||||
|
const simbolos = await ctx.db.query("simbolos").collect();
|
||||||
|
const simbolosCadastrados24h = simbolos.filter(
|
||||||
|
(s) => s._creationTime >= last24h
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
funcionariosCadastrados24h,
|
||||||
|
solicitacoesAcesso24h,
|
||||||
|
simbolosCadastrados24h,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obter distribuição de funcionários por cidade
|
||||||
|
export const getFuncionariosPorCidade = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
cidade: v.string(),
|
||||||
|
quantidade: v.number(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const funcionarios = await ctx.db.query("funcionarios").collect();
|
||||||
|
|
||||||
|
const cidadesMap: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const func of funcionarios) {
|
||||||
|
if (!func.desligamentoData) {
|
||||||
|
cidadesMap[func.cidade] = (cidadesMap[func.cidade] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = Object.entries(cidadesMap)
|
||||||
|
.map(([cidade, quantidade]) => ({ cidade, quantidade }))
|
||||||
|
.sort((a, b) => b.quantidade - a.quantidade)
|
||||||
|
.slice(0, 5); // Top 5 cidades
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obter evolução de cadastros por mês
|
||||||
|
export const getEvolucaoCadastros = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
mes: v.string(),
|
||||||
|
funcionarios: v.number(),
|
||||||
|
solicitacoes: v.number(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const funcionarios = await ctx.db.query("funcionarios").collect();
|
||||||
|
const solicitacoes = await ctx.db.query("solicitacoesAcesso").collect();
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const meses: Array<{ mes: string; funcionarios: number; solicitacoes: number }> = [];
|
||||||
|
|
||||||
|
// Últimos 6 meses
|
||||||
|
for (let i = 5; i >= 0; i--) {
|
||||||
|
const date = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
|
const nextDate = new Date(now.getFullYear(), now.getMonth() - i + 1, 1);
|
||||||
|
|
||||||
|
const mesNome = date.toLocaleDateString("pt-BR", {
|
||||||
|
month: "short",
|
||||||
|
year: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
const funcCount = funcionarios.filter(
|
||||||
|
(f) => f._creationTime >= date.getTime() && f._creationTime < nextDate.getTime()
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const solCount = solicitacoes.filter(
|
||||||
|
(s) => s.dataSolicitacao >= date.getTime() && s.dataSolicitacao < nextDate.getTime()
|
||||||
|
).length;
|
||||||
|
|
||||||
|
meses.push({
|
||||||
|
mes: mesNome,
|
||||||
|
funcionarios: funcCount,
|
||||||
|
solicitacoes: solCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return meses;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
76
packages/backend/convex/inicializarPermissoes.ts
Normal file
76
packages/backend/convex/inicializarPermissoes.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Script para inicializar permissões de menu no sistema
|
||||||
|
*/
|
||||||
|
import { mutation } from "./_generated/server";
|
||||||
|
|
||||||
|
export const inicializarTodasPermissoes = mutation({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
console.log("🔐 Inicializando permissões de menu...");
|
||||||
|
|
||||||
|
// Buscar roles
|
||||||
|
const roles = await ctx.db.query("roles").collect();
|
||||||
|
|
||||||
|
const admin = roles.find((r) => r.nome === "admin");
|
||||||
|
const ti = roles.find((r) => r.nome === "ti");
|
||||||
|
const usuarioAvancado = roles.find((r) => r.nome === "usuario_avancado");
|
||||||
|
const usuario = roles.find((r) => r.nome === "usuario");
|
||||||
|
|
||||||
|
if (!admin || !ti || !usuarioAvancado || !usuario) {
|
||||||
|
throw new Error("Roles não encontradas");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Menus do sistema
|
||||||
|
const menus = [
|
||||||
|
"/recursos-humanos",
|
||||||
|
"/recursos-humanos/funcionarios",
|
||||||
|
"/recursos-humanos/simbolos",
|
||||||
|
"/financeiro",
|
||||||
|
"/controladoria",
|
||||||
|
"/licitacoes",
|
||||||
|
"/compras",
|
||||||
|
"/juridico",
|
||||||
|
"/comunicacao",
|
||||||
|
"/programas-esportivos",
|
||||||
|
"/secretaria-executiva",
|
||||||
|
"/gestao-pessoas",
|
||||||
|
"/ti",
|
||||||
|
];
|
||||||
|
|
||||||
|
let contador = 0;
|
||||||
|
|
||||||
|
// Admin e TI: acesso total é automático no código, não precisa criar permissões
|
||||||
|
// Eles são filtrados no verificarAcesso (nivel <= 1)
|
||||||
|
|
||||||
|
// Usuario Avançado: SEM acesso por padrão (TI define depois)
|
||||||
|
for (const menu of menus) {
|
||||||
|
await ctx.db.insert("menuPermissoes", {
|
||||||
|
roleId: usuarioAvancado._id,
|
||||||
|
menuPath: menu,
|
||||||
|
podeAcessar: false,
|
||||||
|
podeConsultar: false,
|
||||||
|
podeGravar: false,
|
||||||
|
});
|
||||||
|
contador++;
|
||||||
|
}
|
||||||
|
console.log(` ✅ ${contador} permissões criadas para usuario_avancado`);
|
||||||
|
|
||||||
|
// Usuario: SEM acesso por padrão (TI define depois)
|
||||||
|
contador = 0;
|
||||||
|
for (const menu of menus) {
|
||||||
|
await ctx.db.insert("menuPermissoes", {
|
||||||
|
roleId: usuario._id,
|
||||||
|
menuPath: menu,
|
||||||
|
podeAcessar: false,
|
||||||
|
podeConsultar: false,
|
||||||
|
podeGravar: false,
|
||||||
|
});
|
||||||
|
contador++;
|
||||||
|
}
|
||||||
|
console.log(` ✅ ${contador} permissões criadas para usuario`);
|
||||||
|
|
||||||
|
console.log("✅ Permissões inicializadas com sucesso!");
|
||||||
|
return { success: true, message: "Permissões inicializadas" };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
227
packages/backend/convex/logsAcesso.ts
Normal file
227
packages/backend/convex/logsAcesso.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { v } from "convex/values";
|
||||||
|
import { mutation, query } from "./_generated/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listar logs de acesso com filtros
|
||||||
|
*/
|
||||||
|
export const listar = query({
|
||||||
|
args: {
|
||||||
|
usuarioId: v.optional(v.id("usuarios")),
|
||||||
|
tipo: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal("login"),
|
||||||
|
v.literal("logout"),
|
||||||
|
v.literal("acesso_negado"),
|
||||||
|
v.literal("senha_alterada"),
|
||||||
|
v.literal("sessao_expirada")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
dataInicio: v.optional(v.number()),
|
||||||
|
dataFim: v.optional(v.number()),
|
||||||
|
limite: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id("logsAcesso"),
|
||||||
|
tipo: v.union(
|
||||||
|
v.literal("login"),
|
||||||
|
v.literal("logout"),
|
||||||
|
v.literal("acesso_negado"),
|
||||||
|
v.literal("senha_alterada"),
|
||||||
|
v.literal("sessao_expirada")
|
||||||
|
),
|
||||||
|
ipAddress: v.optional(v.string()),
|
||||||
|
userAgent: v.optional(v.string()),
|
||||||
|
detalhes: v.optional(v.string()),
|
||||||
|
timestamp: v.number(),
|
||||||
|
usuario: v.optional(
|
||||||
|
v.object({
|
||||||
|
_id: v.id("usuarios"),
|
||||||
|
matricula: v.string(),
|
||||||
|
nome: v.string(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
let logs;
|
||||||
|
|
||||||
|
// Filtrar por usuário
|
||||||
|
if (args.usuarioId !== undefined) {
|
||||||
|
const usuarioId = args.usuarioId; // TypeScript agora sabe que não é undefined
|
||||||
|
logs = await ctx.db
|
||||||
|
.query("logsAcesso")
|
||||||
|
.withIndex("by_usuario", (q) => q.eq("usuarioId", usuarioId))
|
||||||
|
.collect();
|
||||||
|
} else {
|
||||||
|
logs = await ctx.db
|
||||||
|
.query("logsAcesso")
|
||||||
|
.withIndex("by_timestamp")
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrar por tipo
|
||||||
|
if (args.tipo) {
|
||||||
|
logs = logs.filter((log) => log.tipo === args.tipo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrar por data
|
||||||
|
if (args.dataInicio) {
|
||||||
|
logs = logs.filter((log) => log.timestamp >= args.dataInicio!);
|
||||||
|
}
|
||||||
|
if (args.dataFim) {
|
||||||
|
logs = logs.filter((log) => log.timestamp <= args.dataFim!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordenar por timestamp decrescente
|
||||||
|
logs.sort((a, b) => b.timestamp - a.timestamp);
|
||||||
|
|
||||||
|
// Limitar resultados
|
||||||
|
if (args.limite) {
|
||||||
|
logs = logs.slice(0, args.limite);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar informações dos usuários
|
||||||
|
const resultado = [];
|
||||||
|
for (const log of logs) {
|
||||||
|
let usuario = undefined;
|
||||||
|
if (log.usuarioId) {
|
||||||
|
const user = await ctx.db.get(log.usuarioId);
|
||||||
|
if (user) {
|
||||||
|
usuario = {
|
||||||
|
_id: user._id,
|
||||||
|
matricula: user.matricula,
|
||||||
|
nome: user.nome,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resultado.push({
|
||||||
|
_id: log._id,
|
||||||
|
tipo: log.tipo,
|
||||||
|
ipAddress: log.ipAddress,
|
||||||
|
userAgent: log.userAgent,
|
||||||
|
detalhes: log.detalhes,
|
||||||
|
timestamp: log.timestamp,
|
||||||
|
usuario,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultado;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obter estatísticas de acessos
|
||||||
|
*/
|
||||||
|
export const estatisticas = query({
|
||||||
|
args: {
|
||||||
|
dataInicio: v.optional(v.number()),
|
||||||
|
dataFim: v.optional(v.number()),
|
||||||
|
},
|
||||||
|
returns: v.object({
|
||||||
|
totalLogins: v.number(),
|
||||||
|
totalLogouts: v.number(),
|
||||||
|
totalAcessosNegados: v.number(),
|
||||||
|
totalSenhasAlteradas: v.number(),
|
||||||
|
totalSessoesExpiradas: v.number(),
|
||||||
|
loginsPorDia: v.array(
|
||||||
|
v.object({
|
||||||
|
data: v.string(),
|
||||||
|
quantidade: v.number(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
let logs = await ctx.db.query("logsAcesso").collect();
|
||||||
|
|
||||||
|
// Filtrar por data
|
||||||
|
if (args.dataInicio) {
|
||||||
|
logs = logs.filter((log) => log.timestamp >= args.dataInicio!);
|
||||||
|
}
|
||||||
|
if (args.dataFim) {
|
||||||
|
logs = logs.filter((log) => log.timestamp <= args.dataFim!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contar por tipo
|
||||||
|
const totalLogins = logs.filter((log) => log.tipo === "login").length;
|
||||||
|
const totalLogouts = logs.filter((log) => log.tipo === "logout").length;
|
||||||
|
const totalAcessosNegados = logs.filter(
|
||||||
|
(log) => log.tipo === "acesso_negado"
|
||||||
|
).length;
|
||||||
|
const totalSenhasAlteradas = logs.filter(
|
||||||
|
(log) => log.tipo === "senha_alterada"
|
||||||
|
).length;
|
||||||
|
const totalSessoesExpiradas = logs.filter(
|
||||||
|
(log) => log.tipo === "sessao_expirada"
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Agrupar logins por dia
|
||||||
|
const loginsPorDiaMap = new Map<string, number>();
|
||||||
|
const loginsOnly = logs.filter((log) => log.tipo === "login");
|
||||||
|
|
||||||
|
for (const log of loginsOnly) {
|
||||||
|
const data = new Date(log.timestamp).toISOString().split("T")[0];
|
||||||
|
loginsPorDiaMap.set(data, (loginsPorDiaMap.get(data) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginsPorDia = Array.from(loginsPorDiaMap.entries())
|
||||||
|
.map(([data, quantidade]) => ({ data, quantidade }))
|
||||||
|
.sort((a, b) => a.data.localeCompare(b.data));
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalLogins,
|
||||||
|
totalLogouts,
|
||||||
|
totalAcessosNegados,
|
||||||
|
totalSenhasAlteradas,
|
||||||
|
totalSessoesExpiradas,
|
||||||
|
loginsPorDia,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpar logs antigos (apenas TI)
|
||||||
|
*/
|
||||||
|
export const limpar = mutation({
|
||||||
|
args: {
|
||||||
|
dataLimite: v.number(), // Excluir logs anteriores a esta data
|
||||||
|
},
|
||||||
|
returns: v.object({
|
||||||
|
excluidos: v.number(),
|
||||||
|
}),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const logs = await ctx.db
|
||||||
|
.query("logsAcesso")
|
||||||
|
.withIndex("by_timestamp")
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const logsAntigos = logs.filter((log) => log.timestamp < args.dataLimite);
|
||||||
|
|
||||||
|
for (const log of logsAntigos) {
|
||||||
|
await ctx.db.delete(log._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { excluidos: logsAntigos.length };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpar todos os logs (apenas TI)
|
||||||
|
*/
|
||||||
|
export const limparTodos = mutation({
|
||||||
|
args: {},
|
||||||
|
returns: v.object({
|
||||||
|
excluidos: v.number(),
|
||||||
|
}),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const logs = await ctx.db.query("logsAcesso").collect();
|
||||||
|
|
||||||
|
for (const log of logs) {
|
||||||
|
await ctx.db.delete(log._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { excluidos: logs.length };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
525
packages/backend/convex/menuPermissoes.ts
Normal file
525
packages/backend/convex/menuPermissoes.ts
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
import { v } from "convex/values";
|
||||||
|
import { mutation, query } from "./_generated/server";
|
||||||
|
import type { Id } from "./_generated/dataModel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lista de menus do sistema
|
||||||
|
*/
|
||||||
|
export const MENUS_SISTEMA = [
|
||||||
|
{ path: "/recursos-humanos", nome: "Recursos Humanos", descricao: "Gestão de funcionários e símbolos" },
|
||||||
|
{ path: "/recursos-humanos/funcionarios", nome: "Funcionários", descricao: "Cadastro e gestão de funcionários" },
|
||||||
|
{ path: "/recursos-humanos/simbolos", nome: "Símbolos", descricao: "Cadastro e gestão de símbolos" },
|
||||||
|
{ path: "/financeiro", nome: "Financeiro", descricao: "Gestão financeira" },
|
||||||
|
{ path: "/controladoria", nome: "Controladoria", descricao: "Controle e auditoria" },
|
||||||
|
{ path: "/licitacoes", nome: "Licitações", descricao: "Gestão de licitações" },
|
||||||
|
{ path: "/compras", nome: "Compras", descricao: "Gestão de compras" },
|
||||||
|
{ path: "/juridico", nome: "Jurídico", descricao: "Departamento jurídico" },
|
||||||
|
{ path: "/comunicacao", nome: "Comunicação", descricao: "Gestão de comunicação" },
|
||||||
|
{ path: "/programas-esportivos", nome: "Programas Esportivos", descricao: "Gestão de programas esportivos" },
|
||||||
|
{ path: "/secretaria-executiva", nome: "Secretaria Executiva", descricao: "Secretaria executiva" },
|
||||||
|
{ path: "/gestao-pessoas", nome: "Gestão de Pessoas", descricao: "Gestão de recursos humanos" },
|
||||||
|
{ path: "/ti", nome: "Tecnologia da Informação", descricao: "TI e suporte técnico" },
|
||||||
|
{ path: "/ti/painel-administrativo", nome: "Painel Administrativo TI", descricao: "Painel de administração do sistema" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listar todas as permissões de menu para uma role
|
||||||
|
*/
|
||||||
|
export const listarPorRole = query({
|
||||||
|
args: { roleId: v.id("roles") },
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id("menuPermissoes"),
|
||||||
|
roleId: v.id("roles"),
|
||||||
|
menuPath: v.string(),
|
||||||
|
podeAcessar: v.boolean(),
|
||||||
|
podeConsultar: v.boolean(),
|
||||||
|
podeGravar: v.boolean(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db
|
||||||
|
.query("menuPermissoes")
|
||||||
|
.withIndex("by_role", (q) => q.eq("roleId", args.roleId))
|
||||||
|
.collect();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verificar se um usuário tem permissão para acessar um menu
|
||||||
|
* Prioridade: Permissão personalizada > Permissão da role
|
||||||
|
*/
|
||||||
|
export const verificarAcesso = query({
|
||||||
|
args: {
|
||||||
|
usuarioId: v.id("usuarios"),
|
||||||
|
menuPath: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.object({
|
||||||
|
podeAcessar: v.boolean(),
|
||||||
|
podeConsultar: v.boolean(),
|
||||||
|
podeGravar: v.boolean(),
|
||||||
|
motivo: v.optional(v.string()),
|
||||||
|
}),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Buscar o usuário
|
||||||
|
const usuario = await ctx.db.get(args.usuarioId);
|
||||||
|
if (!usuario) {
|
||||||
|
return {
|
||||||
|
podeAcessar: false,
|
||||||
|
podeConsultar: false,
|
||||||
|
podeGravar: false,
|
||||||
|
motivo: "Usuário não encontrado",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se o usuário está ativo
|
||||||
|
if (!usuario.ativo) {
|
||||||
|
return {
|
||||||
|
podeAcessar: false,
|
||||||
|
podeConsultar: false,
|
||||||
|
podeGravar: false,
|
||||||
|
motivo: "Usuário inativo",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar a role do usuário
|
||||||
|
const role = await ctx.db.get(usuario.roleId);
|
||||||
|
if (!role) {
|
||||||
|
return {
|
||||||
|
podeAcessar: false,
|
||||||
|
podeConsultar: false,
|
||||||
|
podeGravar: false,
|
||||||
|
motivo: "Role não encontrada",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin (nível 0) e TI (nível 1) têm acesso total
|
||||||
|
if (role.nivel <= 1) {
|
||||||
|
return {
|
||||||
|
podeAcessar: true,
|
||||||
|
podeConsultar: true,
|
||||||
|
podeGravar: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard e Solicitar Acesso são públicos
|
||||||
|
if (args.menuPath === "/" || args.menuPath === "/solicitar-acesso") {
|
||||||
|
return {
|
||||||
|
podeAcessar: true,
|
||||||
|
podeConsultar: true,
|
||||||
|
podeGravar: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Verificar se existe permissão personalizada para este usuário
|
||||||
|
const permissaoPersonalizada = await ctx.db
|
||||||
|
.query("menuPermissoesPersonalizadas")
|
||||||
|
.withIndex("by_usuario_and_menu", (q) =>
|
||||||
|
q.eq("usuarioId", args.usuarioId).eq("menuPath", args.menuPath)
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (permissaoPersonalizada) {
|
||||||
|
return {
|
||||||
|
podeAcessar: permissaoPersonalizada.podeAcessar,
|
||||||
|
podeConsultar: permissaoPersonalizada.podeConsultar,
|
||||||
|
podeGravar: permissaoPersonalizada.podeGravar,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Se não houver permissão personalizada, verificar permissão da role
|
||||||
|
const permissaoRole = await ctx.db
|
||||||
|
.query("menuPermissoes")
|
||||||
|
.withIndex("by_role_and_menu", (q) =>
|
||||||
|
q.eq("roleId", usuario.roleId).eq("menuPath", args.menuPath)
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!permissaoRole) {
|
||||||
|
return {
|
||||||
|
podeAcessar: false,
|
||||||
|
podeConsultar: false,
|
||||||
|
podeGravar: false,
|
||||||
|
motivo: "Sem permissão configurada para este menu",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
podeAcessar: permissaoRole.podeAcessar,
|
||||||
|
podeConsultar: permissaoRole.podeConsultar,
|
||||||
|
podeGravar: permissaoRole.podeGravar,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atualizar ou criar permissão de menu para uma role
|
||||||
|
*/
|
||||||
|
export const atualizarPermissao = mutation({
|
||||||
|
args: {
|
||||||
|
roleId: v.id("roles"),
|
||||||
|
menuPath: v.string(),
|
||||||
|
podeAcessar: v.boolean(),
|
||||||
|
podeConsultar: v.boolean(),
|
||||||
|
podeGravar: v.boolean(),
|
||||||
|
},
|
||||||
|
returns: v.id("menuPermissoes"),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Verificar se já existe uma permissão
|
||||||
|
const existente = await ctx.db
|
||||||
|
.query("menuPermissoes")
|
||||||
|
.withIndex("by_role_and_menu", (q) =>
|
||||||
|
q.eq("roleId", args.roleId).eq("menuPath", args.menuPath)
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existente) {
|
||||||
|
// Atualizar permissão existente
|
||||||
|
await ctx.db.patch(existente._id, {
|
||||||
|
podeAcessar: args.podeAcessar,
|
||||||
|
podeConsultar: args.podeConsultar,
|
||||||
|
podeGravar: args.podeGravar,
|
||||||
|
});
|
||||||
|
return existente._id;
|
||||||
|
} else {
|
||||||
|
// Criar nova permissão
|
||||||
|
return await ctx.db.insert("menuPermissoes", {
|
||||||
|
roleId: args.roleId,
|
||||||
|
menuPath: args.menuPath,
|
||||||
|
podeAcessar: args.podeAcessar,
|
||||||
|
podeConsultar: args.podeConsultar,
|
||||||
|
podeGravar: args.podeGravar,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remover permissão de menu
|
||||||
|
*/
|
||||||
|
export const removerPermissao = mutation({
|
||||||
|
args: {
|
||||||
|
permissaoId: v.id("menuPermissoes"),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await ctx.db.delete(args.permissaoId);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inicializar permissões padrão para uma role
|
||||||
|
*/
|
||||||
|
export const inicializarPermissoesRole = mutation({
|
||||||
|
args: {
|
||||||
|
roleId: v.id("roles"),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Buscar a role
|
||||||
|
const role = await ctx.db.get(args.roleId);
|
||||||
|
if (!role) {
|
||||||
|
throw new Error("Role não encontrada");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin e TI não precisam de permissões específicas (acesso total)
|
||||||
|
if (role.nivel <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Para outras roles, criar permissões básicas (apenas consulta)
|
||||||
|
for (const menu of MENUS_SISTEMA) {
|
||||||
|
// Verificar se já existe permissão
|
||||||
|
const existente = await ctx.db
|
||||||
|
.query("menuPermissoes")
|
||||||
|
.withIndex("by_role_and_menu", (q) =>
|
||||||
|
q.eq("roleId", args.roleId).eq("menuPath", menu.path)
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!existente) {
|
||||||
|
// Criar permissão padrão (sem acesso)
|
||||||
|
await ctx.db.insert("menuPermissoes", {
|
||||||
|
roleId: args.roleId,
|
||||||
|
menuPath: menu.path,
|
||||||
|
podeAcessar: false,
|
||||||
|
podeConsultar: false,
|
||||||
|
podeGravar: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listar todos os menus do sistema
|
||||||
|
*/
|
||||||
|
export const listarMenus = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
path: v.string(),
|
||||||
|
nome: v.string(),
|
||||||
|
descricao: v.string(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
return MENUS_SISTEMA.map((menu) => ({
|
||||||
|
path: menu.path,
|
||||||
|
nome: menu.nome,
|
||||||
|
descricao: menu.descricao,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obter matriz de permissões (role x menu) para o painel de controle
|
||||||
|
*/
|
||||||
|
export const obterMatrizPermissoes = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
role: v.object({
|
||||||
|
_id: v.id("roles"),
|
||||||
|
nome: v.string(),
|
||||||
|
nivel: v.number(),
|
||||||
|
descricao: v.string(),
|
||||||
|
}),
|
||||||
|
permissoes: v.array(
|
||||||
|
v.object({
|
||||||
|
menuPath: v.string(),
|
||||||
|
menuNome: v.string(),
|
||||||
|
podeAcessar: v.boolean(),
|
||||||
|
podeConsultar: v.boolean(),
|
||||||
|
podeGravar: v.boolean(),
|
||||||
|
permissaoId: v.optional(v.id("menuPermissoes")),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
// Buscar todas as roles (exceto Admin e TI que têm acesso total)
|
||||||
|
const roles = await ctx.db.query("roles").collect();
|
||||||
|
|
||||||
|
const matriz = [];
|
||||||
|
|
||||||
|
for (const role of roles) {
|
||||||
|
const permissoes = [];
|
||||||
|
|
||||||
|
for (const menu of MENUS_SISTEMA) {
|
||||||
|
// Buscar permissão específica
|
||||||
|
const permissao = await ctx.db
|
||||||
|
.query("menuPermissoes")
|
||||||
|
.withIndex("by_role_and_menu", (q) =>
|
||||||
|
q.eq("roleId", role._id).eq("menuPath", menu.path)
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
// Admin e TI têm acesso total automático
|
||||||
|
if (role.nivel <= 1) {
|
||||||
|
permissoes.push({
|
||||||
|
menuPath: menu.path,
|
||||||
|
menuNome: menu.nome,
|
||||||
|
podeAcessar: true,
|
||||||
|
podeConsultar: true,
|
||||||
|
podeGravar: true,
|
||||||
|
permissaoId: permissao?._id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
permissoes.push({
|
||||||
|
menuPath: menu.path,
|
||||||
|
menuNome: menu.nome,
|
||||||
|
podeAcessar: permissao?.podeAcessar ?? false,
|
||||||
|
podeConsultar: permissao?.podeConsultar ?? false,
|
||||||
|
podeGravar: permissao?.podeGravar ?? false,
|
||||||
|
permissaoId: permissao?._id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
matriz.push({
|
||||||
|
role: {
|
||||||
|
_id: role._id,
|
||||||
|
nome: role.nome,
|
||||||
|
nivel: role.nivel,
|
||||||
|
descricao: role.descricao,
|
||||||
|
},
|
||||||
|
permissoes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return matriz;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Criar ou atualizar permissão personalizada por matrícula
|
||||||
|
*/
|
||||||
|
export const atualizarPermissaoPersonalizada = mutation({
|
||||||
|
args: {
|
||||||
|
matricula: v.string(),
|
||||||
|
menuPath: v.string(),
|
||||||
|
podeAcessar: v.boolean(),
|
||||||
|
podeConsultar: v.boolean(),
|
||||||
|
podeGravar: v.boolean(),
|
||||||
|
},
|
||||||
|
returns: v.union(v.id("menuPermissoesPersonalizadas"), v.null()),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Buscar usuário pela matrícula
|
||||||
|
const usuario = await ctx.db
|
||||||
|
.query("usuarios")
|
||||||
|
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!usuario) {
|
||||||
|
throw new Error("Usuário não encontrado com esta matrícula");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se já existe permissão personalizada
|
||||||
|
const existente = await ctx.db
|
||||||
|
.query("menuPermissoesPersonalizadas")
|
||||||
|
.withIndex("by_usuario_and_menu", (q) =>
|
||||||
|
q.eq("usuarioId", usuario._id).eq("menuPath", args.menuPath)
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existente) {
|
||||||
|
// Atualizar permissão existente
|
||||||
|
await ctx.db.patch(existente._id, {
|
||||||
|
podeAcessar: args.podeAcessar,
|
||||||
|
podeConsultar: args.podeConsultar,
|
||||||
|
podeGravar: args.podeGravar,
|
||||||
|
});
|
||||||
|
return existente._id;
|
||||||
|
} else {
|
||||||
|
// Criar nova permissão
|
||||||
|
return await ctx.db.insert("menuPermissoesPersonalizadas", {
|
||||||
|
usuarioId: usuario._id,
|
||||||
|
matricula: args.matricula,
|
||||||
|
menuPath: args.menuPath,
|
||||||
|
podeAcessar: args.podeAcessar,
|
||||||
|
podeConsultar: args.podeConsultar,
|
||||||
|
podeGravar: args.podeGravar,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remover permissão personalizada
|
||||||
|
*/
|
||||||
|
export const removerPermissaoPersonalizada = mutation({
|
||||||
|
args: {
|
||||||
|
permissaoId: v.id("menuPermissoesPersonalizadas"),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await ctx.db.delete(args.permissaoId);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listar permissões personalizadas de um usuário por matrícula
|
||||||
|
*/
|
||||||
|
export const listarPermissoesPersonalizadas = query({
|
||||||
|
args: {
|
||||||
|
matricula: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id("menuPermissoesPersonalizadas"),
|
||||||
|
menuPath: v.string(),
|
||||||
|
menuNome: v.string(),
|
||||||
|
podeAcessar: v.boolean(),
|
||||||
|
podeConsultar: v.boolean(),
|
||||||
|
podeGravar: v.boolean(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Buscar usuário
|
||||||
|
const usuario = await ctx.db
|
||||||
|
.query("usuarios")
|
||||||
|
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!usuario) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar permissões personalizadas
|
||||||
|
const permissoes = await ctx.db
|
||||||
|
.query("menuPermissoesPersonalizadas")
|
||||||
|
.withIndex("by_usuario", (q) => q.eq("usuarioId", usuario._id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Mapear com nomes dos menus
|
||||||
|
return permissoes.map((p) => {
|
||||||
|
const menu = MENUS_SISTEMA.find((m) => m.path === p.menuPath);
|
||||||
|
return {
|
||||||
|
_id: p._id,
|
||||||
|
menuPath: p.menuPath,
|
||||||
|
menuNome: menu?.nome || p.menuPath,
|
||||||
|
podeAcessar: p.podeAcessar,
|
||||||
|
podeConsultar: p.podeConsultar,
|
||||||
|
podeGravar: p.podeGravar,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar usuário por matrícula para o painel de personalização
|
||||||
|
*/
|
||||||
|
export const buscarUsuarioPorMatricula = query({
|
||||||
|
args: {
|
||||||
|
matricula: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.union(
|
||||||
|
v.object({
|
||||||
|
_id: v.id("usuarios"),
|
||||||
|
matricula: v.string(),
|
||||||
|
nome: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
role: v.object({
|
||||||
|
nome: v.string(),
|
||||||
|
nivel: v.number(),
|
||||||
|
descricao: v.string(),
|
||||||
|
}),
|
||||||
|
ativo: v.boolean(),
|
||||||
|
}),
|
||||||
|
v.null()
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await ctx.db
|
||||||
|
.query("usuarios")
|
||||||
|
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!usuario) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = await ctx.db.get(usuario.roleId);
|
||||||
|
if (!role) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: usuario._id,
|
||||||
|
matricula: usuario.matricula,
|
||||||
|
nome: usuario.nome,
|
||||||
|
email: usuario.email,
|
||||||
|
role: {
|
||||||
|
nome: role.nome,
|
||||||
|
nivel: role.nivel,
|
||||||
|
descricao: role.descricao,
|
||||||
|
},
|
||||||
|
ativo: usuario.ativo,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
146
packages/backend/convex/monitoramento.ts
Normal file
146
packages/backend/convex/monitoramento.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { query } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obter estatísticas em tempo real do sistema
|
||||||
|
*/
|
||||||
|
export const getStatusSistema = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.object({
|
||||||
|
usuariosOnline: v.number(),
|
||||||
|
totalRegistros: v.number(),
|
||||||
|
tempoMedioResposta: v.number(),
|
||||||
|
memoriaUsada: v.number(),
|
||||||
|
cpuUsada: v.number(),
|
||||||
|
ultimaAtualizacao: v.number(),
|
||||||
|
}),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
// Contar usuários online (sessões ativas nos últimos 5 minutos)
|
||||||
|
const cincoMinutosAtras = Date.now() - 5 * 60 * 1000;
|
||||||
|
const sessoesAtivas = await ctx.db
|
||||||
|
.query("sessoes")
|
||||||
|
.filter((q) =>
|
||||||
|
q.and(
|
||||||
|
q.eq(q.field("ativo"), true),
|
||||||
|
q.gt(q.field("criadoEm"), cincoMinutosAtras)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.collect();
|
||||||
|
const usuariosOnline = sessoesAtivas.length;
|
||||||
|
|
||||||
|
// Contar total de registros no banco de dados
|
||||||
|
const [funcionarios, simbolos, usuarios, solicitacoes] = await Promise.all([
|
||||||
|
ctx.db.query("funcionarios").collect(),
|
||||||
|
ctx.db.query("simbolos").collect(),
|
||||||
|
ctx.db.query("usuarios").collect(),
|
||||||
|
ctx.db.query("solicitacoesAcesso").collect(),
|
||||||
|
]);
|
||||||
|
const totalRegistros = funcionarios.length + simbolos.length + usuarios.length + solicitacoes.length;
|
||||||
|
|
||||||
|
// Calcular tempo médio de resposta (simulado baseado em logs recentes)
|
||||||
|
const logsRecentes = await ctx.db
|
||||||
|
.query("logsAcesso")
|
||||||
|
.order("desc")
|
||||||
|
.take(100);
|
||||||
|
|
||||||
|
// Simular tempo médio de resposta (em ms) baseado na quantidade de logs
|
||||||
|
const tempoMedioResposta = logsRecentes.length > 0
|
||||||
|
? Math.round(50 + Math.random() * 150) // 50-200ms
|
||||||
|
: 100;
|
||||||
|
|
||||||
|
// Simular uso de memória e CPU (valores fictícios para demonstração)
|
||||||
|
const memoriaUsada = Math.round(45 + Math.random() * 15); // 45-60%
|
||||||
|
const cpuUsada = Math.round(20 + Math.random() * 30); // 20-50%
|
||||||
|
|
||||||
|
return {
|
||||||
|
usuariosOnline,
|
||||||
|
totalRegistros,
|
||||||
|
tempoMedioResposta,
|
||||||
|
memoriaUsada,
|
||||||
|
cpuUsada,
|
||||||
|
ultimaAtualizacao: Date.now(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obter histórico de atividades do banco de dados (últimos 60 segundos)
|
||||||
|
*/
|
||||||
|
export const getAtividadeBancoDados = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.object({
|
||||||
|
historico: v.array(
|
||||||
|
v.object({
|
||||||
|
timestamp: v.number(),
|
||||||
|
entradas: v.number(),
|
||||||
|
saidas: v.number(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const agora = Date.now();
|
||||||
|
const umMinutoAtras = agora - 60 * 1000;
|
||||||
|
|
||||||
|
// Obter logs de acesso do último minuto
|
||||||
|
const logsRecentes = await ctx.db
|
||||||
|
.query("logsAcesso")
|
||||||
|
.filter((q) => q.gt(q.field("timestamp"), umMinutoAtras))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Agrupar por segundos (intervalos de 5 segundos para suavizar)
|
||||||
|
const historico: Array<{ timestamp: number; entradas: number; saidas: number }> = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const timestampInicio = umMinutoAtras + i * 5000;
|
||||||
|
const timestampFim = timestampInicio + 5000;
|
||||||
|
|
||||||
|
const logsNoIntervalo = logsRecentes.filter(
|
||||||
|
(log) => log.timestamp >= timestampInicio && log.timestamp < timestampFim
|
||||||
|
);
|
||||||
|
|
||||||
|
const entradas = logsNoIntervalo.filter((log) => log.tipo === "login").length;
|
||||||
|
const saidas = logsNoIntervalo.filter((log) => log.tipo === "logout").length;
|
||||||
|
|
||||||
|
historico.push({
|
||||||
|
timestamp: timestampInicio,
|
||||||
|
entradas: entradas + Math.round(Math.random() * 3), // Adicionar variação simulada
|
||||||
|
saidas: saidas + Math.round(Math.random() * 2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { historico };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obter distribuição de tipos de requisições
|
||||||
|
*/
|
||||||
|
export const getDistribuicaoRequisicoes = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.object({
|
||||||
|
queries: v.number(),
|
||||||
|
mutations: v.number(),
|
||||||
|
leituras: v.number(),
|
||||||
|
escritas: v.number(),
|
||||||
|
}),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const logs = await ctx.db
|
||||||
|
.query("logsAcesso")
|
||||||
|
.order("desc")
|
||||||
|
.take(1000);
|
||||||
|
|
||||||
|
// Simular distribuição de tipos de requisições
|
||||||
|
const queries = Math.round(logs.length * 0.6 + Math.random() * 50);
|
||||||
|
const mutations = Math.round(logs.length * 0.3 + Math.random() * 30);
|
||||||
|
const leituras = Math.round(logs.length * 0.7 + Math.random() * 40);
|
||||||
|
const escritas = Math.round(logs.length * 0.3 + Math.random() * 20);
|
||||||
|
|
||||||
|
return {
|
||||||
|
queries,
|
||||||
|
mutations,
|
||||||
|
leituras,
|
||||||
|
escritas,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
44
packages/backend/convex/roles.ts
Normal file
44
packages/backend/convex/roles.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { v } from "convex/values";
|
||||||
|
import { query } from "./_generated/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listar todas as roles
|
||||||
|
*/
|
||||||
|
export const listar = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id("roles"),
|
||||||
|
nome: v.string(),
|
||||||
|
descricao: v.string(),
|
||||||
|
nivel: v.number(),
|
||||||
|
setor: v.optional(v.string()),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
return await ctx.db.query("roles").collect();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buscar role por ID
|
||||||
|
*/
|
||||||
|
export const buscarPorId = query({
|
||||||
|
args: {
|
||||||
|
roleId: v.id("roles"),
|
||||||
|
},
|
||||||
|
returns: v.union(
|
||||||
|
v.object({
|
||||||
|
_id: v.id("roles"),
|
||||||
|
nome: v.string(),
|
||||||
|
descricao: v.string(),
|
||||||
|
nivel: v.number(),
|
||||||
|
setor: v.optional(v.string()),
|
||||||
|
}),
|
||||||
|
v.null()
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db.get(args.roleId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -61,4 +61,132 @@ export default defineSchema({
|
|||||||
repValor: v.string(),
|
repValor: v.string(),
|
||||||
valor: v.string(),
|
valor: v.string(),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
solicitacoesAcesso: defineTable({
|
||||||
|
nome: v.string(),
|
||||||
|
matricula: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
telefone: v.string(),
|
||||||
|
status: v.union(
|
||||||
|
v.literal("pendente"),
|
||||||
|
v.literal("aprovado"),
|
||||||
|
v.literal("rejeitado")
|
||||||
|
),
|
||||||
|
dataSolicitacao: v.number(),
|
||||||
|
dataResposta: v.optional(v.number()),
|
||||||
|
observacoes: v.optional(v.string()),
|
||||||
|
})
|
||||||
|
.index("by_status", ["status"])
|
||||||
|
.index("by_matricula", ["matricula"])
|
||||||
|
.index("by_email", ["email"]),
|
||||||
|
|
||||||
|
// Sistema de Autenticação e Controle de Acesso
|
||||||
|
usuarios: defineTable({
|
||||||
|
matricula: v.string(),
|
||||||
|
senhaHash: v.string(), // Senha criptografada com bcrypt
|
||||||
|
nome: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
funcionarioId: v.optional(v.id("funcionarios")),
|
||||||
|
roleId: v.id("roles"),
|
||||||
|
ativo: v.boolean(),
|
||||||
|
primeiroAcesso: v.boolean(),
|
||||||
|
ultimoAcesso: v.optional(v.number()),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number(),
|
||||||
|
})
|
||||||
|
.index("by_matricula", ["matricula"])
|
||||||
|
.index("by_email", ["email"])
|
||||||
|
.index("by_role", ["roleId"])
|
||||||
|
.index("by_ativo", ["ativo"]),
|
||||||
|
|
||||||
|
roles: defineTable({
|
||||||
|
nome: v.string(), // "admin", "ti", "usuario_avancado", "usuario"
|
||||||
|
descricao: v.string(),
|
||||||
|
nivel: v.number(), // 0 = admin, 1 = ti, 2 = usuario_avancado, 3 = usuario
|
||||||
|
setor: v.optional(v.string()), // "ti", "rh", "financeiro", etc.
|
||||||
|
})
|
||||||
|
.index("by_nome", ["nome"])
|
||||||
|
.index("by_nivel", ["nivel"])
|
||||||
|
.index("by_setor", ["setor"]),
|
||||||
|
|
||||||
|
permissoes: defineTable({
|
||||||
|
nome: v.string(), // "funcionarios.criar", "simbolos.editar", etc.
|
||||||
|
descricao: v.string(),
|
||||||
|
recurso: v.string(), // "funcionarios", "simbolos", "usuarios", etc.
|
||||||
|
acao: v.string(), // "criar", "ler", "editar", "excluir"
|
||||||
|
})
|
||||||
|
.index("by_recurso", ["recurso"])
|
||||||
|
.index("by_nome", ["nome"]),
|
||||||
|
|
||||||
|
rolePermissoes: defineTable({
|
||||||
|
roleId: v.id("roles"),
|
||||||
|
permissaoId: v.id("permissoes"),
|
||||||
|
})
|
||||||
|
.index("by_role", ["roleId"])
|
||||||
|
.index("by_permissao", ["permissaoId"]),
|
||||||
|
|
||||||
|
// Permissões de Menu (granulares por role)
|
||||||
|
menuPermissoes: defineTable({
|
||||||
|
roleId: v.id("roles"),
|
||||||
|
menuPath: v.string(), // "/recursos-humanos", "/financeiro", etc.
|
||||||
|
podeAcessar: v.boolean(),
|
||||||
|
podeConsultar: v.boolean(), // Pode apenas visualizar
|
||||||
|
podeGravar: v.boolean(), // Pode criar/editar/excluir
|
||||||
|
})
|
||||||
|
.index("by_role", ["roleId"])
|
||||||
|
.index("by_menu", ["menuPath"])
|
||||||
|
.index("by_role_and_menu", ["roleId", "menuPath"]),
|
||||||
|
|
||||||
|
// Permissões de Menu Personalizadas (por matrícula)
|
||||||
|
menuPermissoesPersonalizadas: defineTable({
|
||||||
|
usuarioId: v.id("usuarios"),
|
||||||
|
matricula: v.string(), // Para facilitar busca
|
||||||
|
menuPath: v.string(),
|
||||||
|
podeAcessar: v.boolean(),
|
||||||
|
podeConsultar: v.boolean(),
|
||||||
|
podeGravar: v.boolean(),
|
||||||
|
})
|
||||||
|
.index("by_usuario", ["usuarioId"])
|
||||||
|
.index("by_matricula", ["matricula"])
|
||||||
|
.index("by_usuario_and_menu", ["usuarioId", "menuPath"])
|
||||||
|
.index("by_matricula_and_menu", ["matricula", "menuPath"]),
|
||||||
|
|
||||||
|
sessoes: defineTable({
|
||||||
|
usuarioId: v.id("usuarios"),
|
||||||
|
token: v.string(),
|
||||||
|
ipAddress: v.optional(v.string()),
|
||||||
|
userAgent: v.optional(v.string()),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
expiraEm: v.number(),
|
||||||
|
ativo: v.boolean(),
|
||||||
|
})
|
||||||
|
.index("by_usuario", ["usuarioId"])
|
||||||
|
.index("by_token", ["token"])
|
||||||
|
.index("by_ativo", ["ativo"])
|
||||||
|
.index("by_expiracao", ["expiraEm"]),
|
||||||
|
|
||||||
|
logsAcesso: defineTable({
|
||||||
|
usuarioId: v.id("usuarios"),
|
||||||
|
tipo: v.union(
|
||||||
|
v.literal("login"),
|
||||||
|
v.literal("logout"),
|
||||||
|
v.literal("acesso_negado"),
|
||||||
|
v.literal("senha_alterada"),
|
||||||
|
v.literal("sessao_expirada")
|
||||||
|
),
|
||||||
|
ipAddress: v.optional(v.string()),
|
||||||
|
userAgent: v.optional(v.string()),
|
||||||
|
detalhes: v.optional(v.string()),
|
||||||
|
timestamp: v.number(),
|
||||||
|
})
|
||||||
|
.index("by_usuario", ["usuarioId"])
|
||||||
|
.index("by_tipo", ["tipo"])
|
||||||
|
.index("by_timestamp", ["timestamp"]),
|
||||||
|
|
||||||
|
configuracaoAcesso: defineTable({
|
||||||
|
chave: v.string(), // "sessao_duracao", "max_tentativas_login", etc.
|
||||||
|
valor: v.string(),
|
||||||
|
descricao: v.string(),
|
||||||
|
})
|
||||||
|
.index("by_chave", ["chave"]),
|
||||||
});
|
});
|
||||||
|
|||||||
425
packages/backend/convex/seed.ts
Normal file
425
packages/backend/convex/seed.ts
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
import { internalMutation } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
import { hashPassword } from "./auth/utils";
|
||||||
|
|
||||||
|
// Dados exportados do Convex Cloud
|
||||||
|
const simbolosData = [
|
||||||
|
{
|
||||||
|
descricao: "Cargo de Direção e Assessoramento Superior - 5",
|
||||||
|
nome: "DAS-5",
|
||||||
|
repValor: "4747.84",
|
||||||
|
tipo: "cargo_comissionado" as const,
|
||||||
|
valor: "5934.80",
|
||||||
|
vencValor: "1186.96",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
descricao: "Cargo de Direção e Assessoramento Superior - 3",
|
||||||
|
nome: "DAS-3",
|
||||||
|
repValor: "6273.92",
|
||||||
|
tipo: "cargo_comissionado" as const,
|
||||||
|
valor: "7842.40",
|
||||||
|
vencValor: "1568.48",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
descricao: "Cargo de Direção e Assessoramento Superior -2",
|
||||||
|
nome: "DAS - 2",
|
||||||
|
repValor: "7460.87",
|
||||||
|
tipo: "cargo_comissionado" as const,
|
||||||
|
valor: "9326.09",
|
||||||
|
vencValor: "1865.22",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
descricao: "Cargo de Apoio e Assessoramento - 1",
|
||||||
|
nome: "CAA-1",
|
||||||
|
repValor: "4120.43",
|
||||||
|
tipo: "cargo_comissionado" as const,
|
||||||
|
valor: "5150.54",
|
||||||
|
vencValor: "1030.11",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
descricao: "Função Gratificada de Direção e Assessoramento",
|
||||||
|
nome: "FDA",
|
||||||
|
repValor: "",
|
||||||
|
tipo: "funcao_gratificada" as const,
|
||||||
|
valor: "7460.87",
|
||||||
|
vencValor: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
descricao: "Função Gratificada de Supervisão - 3",
|
||||||
|
nome: "CAA - 3",
|
||||||
|
repValor: "2204.36",
|
||||||
|
tipo: "cargo_comissionado" as const,
|
||||||
|
valor: "2755.45",
|
||||||
|
vencValor: "551.09",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
descricao: "Função Gratificada de Direção e Assessoramento -1",
|
||||||
|
nome: "FDA-1",
|
||||||
|
repValor: "",
|
||||||
|
tipo: "funcao_gratificada" as const,
|
||||||
|
valor: "6273.92",
|
||||||
|
vencValor: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
descricao: "Função Gratificada de Direção e Assessoramento -2",
|
||||||
|
nome: "FDA -2",
|
||||||
|
repValor: "",
|
||||||
|
tipo: "funcao_gratificada" as const,
|
||||||
|
valor: "5765.22",
|
||||||
|
vencValor: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
descricao: "Função Gratificada de Direção e Assessoramento - 3",
|
||||||
|
nome: "FDA - 3",
|
||||||
|
repValor: "",
|
||||||
|
tipo: "funcao_gratificada" as const,
|
||||||
|
valor: "4747.83",
|
||||||
|
vencValor: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
descricao: "Função Gratificada de Direção e Assessoramento - 4",
|
||||||
|
nome: "FDA - 4",
|
||||||
|
repValor: "",
|
||||||
|
tipo: "funcao_gratificada" as const,
|
||||||
|
valor: "3391.31",
|
||||||
|
vencValor: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
descricao: "Função Gratificada de Supervisão - 1",
|
||||||
|
nome: "FGS -1 ",
|
||||||
|
repValor: "",
|
||||||
|
tipo: "funcao_gratificada" as const,
|
||||||
|
valor: "1532.08",
|
||||||
|
vencValor: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
descricao: "Função Gratificada de Supervisão - 2",
|
||||||
|
nome: "FGS - 2",
|
||||||
|
repValor: "",
|
||||||
|
tipo: "funcao_gratificada" as const,
|
||||||
|
valor: "934.74",
|
||||||
|
vencValor: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
descricao: "Função Gratificada de Supervisão - 2",
|
||||||
|
nome: "CAA - 2",
|
||||||
|
repValor: "3391.31",
|
||||||
|
tipo: "cargo_comissionado" as const,
|
||||||
|
valor: "4239.14",
|
||||||
|
vencValor: "847.83",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const funcionariosData = [
|
||||||
|
{
|
||||||
|
admissaoData: "01/01/2000",
|
||||||
|
cep: "50740500",
|
||||||
|
cidade: "Recife",
|
||||||
|
cpf: "04281554645",
|
||||||
|
email: "kilder@kilder.com.br",
|
||||||
|
endereco: "Rua Bernardino Alves Maia, Várzea",
|
||||||
|
matricula: "4585",
|
||||||
|
nascimento: "01/01/2000",
|
||||||
|
nome: "Madson Kilder",
|
||||||
|
rg: "123456122",
|
||||||
|
simboloNome: "DAS-3", // Será convertido para ID
|
||||||
|
simboloTipo: "cargo_comissionado" as const,
|
||||||
|
telefone: "8101234564",
|
||||||
|
uf: "PE",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
admissaoData: "01/01/2000",
|
||||||
|
cep: "50740400",
|
||||||
|
cidade: "Recife",
|
||||||
|
cpf: "05129038401",
|
||||||
|
email: "princesalves@gmail.com",
|
||||||
|
endereco: "Rua Deputado Cunha Rabelo, Várzea",
|
||||||
|
matricula: "123456",
|
||||||
|
nascimento: "05/01/1985",
|
||||||
|
nome: "Princes Alves rocha wanderley",
|
||||||
|
rg: "639541200",
|
||||||
|
simboloNome: "FDA-1", // Será convertido para ID
|
||||||
|
simboloTipo: "funcao_gratificada" as const,
|
||||||
|
telefone: "81123456455",
|
||||||
|
uf: "PE",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
admissaoData: "01/10/2025",
|
||||||
|
cep: "50740400",
|
||||||
|
cidade: "Recife",
|
||||||
|
cpf: "06102637496",
|
||||||
|
email: "deyvison.wanderley@gmail.com",
|
||||||
|
endereco: "Rua Deputado Cunha Rabelo, Várzea",
|
||||||
|
matricula: "256220",
|
||||||
|
nascimento: "16/03/1985",
|
||||||
|
nome: "Deyvison de França Wanderley",
|
||||||
|
rg: "6347974",
|
||||||
|
simboloNome: "CAA-1", // Será convertido para ID
|
||||||
|
simboloTipo: "cargo_comissionado" as const,
|
||||||
|
telefone: "81994235551",
|
||||||
|
uf: "PE",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const solicitacoesAcessoData = [
|
||||||
|
{
|
||||||
|
dataResposta: 1761445098933,
|
||||||
|
dataSolicitacao: 1761445038329,
|
||||||
|
email: "severino@gmail.com",
|
||||||
|
matricula: "3231",
|
||||||
|
nome: "Severino Gates",
|
||||||
|
observacoes: "Aprovação realizada por Deyvison",
|
||||||
|
status: "aprovado" as const,
|
||||||
|
telefone: "(81) 9942-3551",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataSolicitacao: 1761445187258,
|
||||||
|
email: "michaeljackson@gmail.com",
|
||||||
|
matricula: "123321",
|
||||||
|
nome: "Michael Jackson",
|
||||||
|
status: "pendente" as const,
|
||||||
|
telefone: "(81) 99423-5551",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed inicial do banco de dados com os dados exportados do Convex Cloud
|
||||||
|
*/
|
||||||
|
export const seedDatabase = internalMutation({
|
||||||
|
args: {},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
console.log("🌱 Iniciando seed do banco de dados...");
|
||||||
|
|
||||||
|
// 1. Criar Roles
|
||||||
|
console.log("🔐 Criando roles...");
|
||||||
|
const roleAdmin = await ctx.db.insert("roles", {
|
||||||
|
nome: "admin",
|
||||||
|
descricao: "Administrador do Sistema",
|
||||||
|
nivel: 0,
|
||||||
|
});
|
||||||
|
console.log(" ✅ Role criada: admin");
|
||||||
|
|
||||||
|
const roleTI = await ctx.db.insert("roles", {
|
||||||
|
nome: "ti",
|
||||||
|
descricao: "Tecnologia da Informação",
|
||||||
|
nivel: 1,
|
||||||
|
setor: "ti",
|
||||||
|
});
|
||||||
|
console.log(" ✅ Role criada: ti");
|
||||||
|
|
||||||
|
const roleUsuarioAvancado = await ctx.db.insert("roles", {
|
||||||
|
nome: "usuario_avancado",
|
||||||
|
descricao: "Usuário Avançado",
|
||||||
|
nivel: 2,
|
||||||
|
});
|
||||||
|
console.log(" ✅ Role criada: usuario_avancado");
|
||||||
|
|
||||||
|
const roleUsuario = await ctx.db.insert("roles", {
|
||||||
|
nome: "usuario",
|
||||||
|
descricao: "Usuário Comum",
|
||||||
|
nivel: 3,
|
||||||
|
});
|
||||||
|
console.log(" ✅ Role criada: usuario");
|
||||||
|
|
||||||
|
// 2. Criar usuário admin inicial
|
||||||
|
console.log("👤 Criando usuário admin...");
|
||||||
|
const senhaAdmin = await hashPassword("Admin@123");
|
||||||
|
await ctx.db.insert("usuarios", {
|
||||||
|
matricula: "0000",
|
||||||
|
senhaHash: senhaAdmin,
|
||||||
|
nome: "Administrador",
|
||||||
|
email: "admin@sgse.pe.gov.br",
|
||||||
|
roleId: roleAdmin as any,
|
||||||
|
ativo: true,
|
||||||
|
primeiroAcesso: false,
|
||||||
|
criadoEm: Date.now(),
|
||||||
|
atualizadoEm: Date.now(),
|
||||||
|
});
|
||||||
|
console.log(" ✅ Usuário admin criado (matrícula: 0000, senha: Admin@123)");
|
||||||
|
|
||||||
|
// 3. Inserir símbolos
|
||||||
|
console.log("📝 Inserindo símbolos...");
|
||||||
|
const simbolosMap = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const simbolo of simbolosData) {
|
||||||
|
const id = await ctx.db.insert("simbolos", {
|
||||||
|
descricao: simbolo.descricao,
|
||||||
|
nome: simbolo.nome,
|
||||||
|
repValor: simbolo.repValor,
|
||||||
|
tipo: simbolo.tipo,
|
||||||
|
valor: simbolo.valor,
|
||||||
|
vencValor: simbolo.vencValor,
|
||||||
|
});
|
||||||
|
simbolosMap.set(simbolo.nome, id);
|
||||||
|
console.log(` ✅ Símbolo criado: ${simbolo.nome}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Inserir funcionários
|
||||||
|
console.log("👥 Inserindo funcionários...");
|
||||||
|
const funcionariosMap = new Map<string, string>();
|
||||||
|
for (const funcionario of funcionariosData) {
|
||||||
|
const simboloId = simbolosMap.get(funcionario.simboloNome);
|
||||||
|
if (!simboloId) {
|
||||||
|
console.error(` ❌ Símbolo não encontrado: ${funcionario.simboloNome}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const funcId = await ctx.db.insert("funcionarios", {
|
||||||
|
admissaoData: funcionario.admissaoData,
|
||||||
|
cep: funcionario.cep,
|
||||||
|
cidade: funcionario.cidade,
|
||||||
|
cpf: funcionario.cpf,
|
||||||
|
email: funcionario.email,
|
||||||
|
endereco: funcionario.endereco,
|
||||||
|
matricula: funcionario.matricula,
|
||||||
|
nascimento: funcionario.nascimento,
|
||||||
|
nome: funcionario.nome,
|
||||||
|
rg: funcionario.rg,
|
||||||
|
simboloId: simboloId as any,
|
||||||
|
simboloTipo: funcionario.simboloTipo,
|
||||||
|
telefone: funcionario.telefone,
|
||||||
|
uf: funcionario.uf,
|
||||||
|
});
|
||||||
|
funcionariosMap.set(funcionario.matricula, funcId);
|
||||||
|
console.log(` ✅ Funcionário criado: ${funcionario.nome}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Criar usuários para os funcionários
|
||||||
|
console.log("👤 Criando usuários para funcionários...");
|
||||||
|
for (const funcionario of funcionariosData) {
|
||||||
|
const funcId = funcionariosMap.get(funcionario.matricula);
|
||||||
|
if (!funcId) continue;
|
||||||
|
|
||||||
|
const senhaInicial = await hashPassword("Mudar@123");
|
||||||
|
await ctx.db.insert("usuarios", {
|
||||||
|
matricula: funcionario.matricula,
|
||||||
|
senhaHash: senhaInicial,
|
||||||
|
nome: funcionario.nome,
|
||||||
|
email: funcionario.email,
|
||||||
|
funcionarioId: funcId as any,
|
||||||
|
roleId: roleUsuario as any,
|
||||||
|
ativo: true,
|
||||||
|
primeiroAcesso: true,
|
||||||
|
criadoEm: Date.now(),
|
||||||
|
atualizadoEm: Date.now(),
|
||||||
|
});
|
||||||
|
console.log(` ✅ Usuário criado: ${funcionario.nome} (senha: Mudar@123)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Inserir solicitações de acesso
|
||||||
|
console.log("📋 Inserindo solicitações de acesso...");
|
||||||
|
for (const solicitacao of solicitacoesAcessoData) {
|
||||||
|
await ctx.db.insert("solicitacoesAcesso", {
|
||||||
|
dataResposta: solicitacao.dataResposta,
|
||||||
|
dataSolicitacao: solicitacao.dataSolicitacao,
|
||||||
|
email: solicitacao.email,
|
||||||
|
matricula: solicitacao.matricula,
|
||||||
|
nome: solicitacao.nome,
|
||||||
|
observacoes: solicitacao.observacoes,
|
||||||
|
status: solicitacao.status,
|
||||||
|
telefone: solicitacao.telefone,
|
||||||
|
});
|
||||||
|
console.log(` ✅ Solicitação criada: ${solicitacao.nome}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✨ Seed concluído com sucesso!");
|
||||||
|
console.log("");
|
||||||
|
console.log("🔑 CREDENCIAIS DE ACESSO:");
|
||||||
|
console.log(" Admin: matrícula 0000, senha Admin@123");
|
||||||
|
console.log(" Funcionários: usar matrícula, senha Mudar@123");
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpar todos os dados do banco
|
||||||
|
*/
|
||||||
|
export const clearDatabase = internalMutation({
|
||||||
|
args: {},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
console.log("🗑️ Limpando banco de dados...");
|
||||||
|
|
||||||
|
// Limpar logs de acesso
|
||||||
|
const logs = await ctx.db.query("logsAcesso").collect();
|
||||||
|
for (const log of logs) {
|
||||||
|
await ctx.db.delete(log._id);
|
||||||
|
}
|
||||||
|
console.log(` ✅ ${logs.length} logs de acesso removidos`);
|
||||||
|
|
||||||
|
// Limpar sessões
|
||||||
|
const sessoes = await ctx.db.query("sessoes").collect();
|
||||||
|
for (const sessao of sessoes) {
|
||||||
|
await ctx.db.delete(sessao._id);
|
||||||
|
}
|
||||||
|
console.log(` ✅ ${sessoes.length} sessões removidas`);
|
||||||
|
|
||||||
|
// Limpar usuários
|
||||||
|
const usuarios = await ctx.db.query("usuarios").collect();
|
||||||
|
for (const usuario of usuarios) {
|
||||||
|
await ctx.db.delete(usuario._id);
|
||||||
|
}
|
||||||
|
console.log(` ✅ ${usuarios.length} usuários removidos`);
|
||||||
|
|
||||||
|
// Limpar funcionários
|
||||||
|
const funcionarios = await ctx.db.query("funcionarios").collect();
|
||||||
|
for (const funcionario of funcionarios) {
|
||||||
|
await ctx.db.delete(funcionario._id);
|
||||||
|
}
|
||||||
|
console.log(` ✅ ${funcionarios.length} funcionários removidos`);
|
||||||
|
|
||||||
|
// Limpar símbolos
|
||||||
|
const simbolos = await ctx.db.query("simbolos").collect();
|
||||||
|
for (const simbolo of simbolos) {
|
||||||
|
await ctx.db.delete(simbolo._id);
|
||||||
|
}
|
||||||
|
console.log(` ✅ ${simbolos.length} símbolos removidos`);
|
||||||
|
|
||||||
|
// Limpar solicitações de acesso
|
||||||
|
const solicitacoes = await ctx.db.query("solicitacoesAcesso").collect();
|
||||||
|
for (const solicitacao of solicitacoes) {
|
||||||
|
await ctx.db.delete(solicitacao._id);
|
||||||
|
}
|
||||||
|
console.log(` ✅ ${solicitacoes.length} solicitações removidas`);
|
||||||
|
|
||||||
|
// Limpar menu-permissões
|
||||||
|
const menuPermissoes = await ctx.db.query("menuPermissoes").collect();
|
||||||
|
for (const mp of menuPermissoes) {
|
||||||
|
await ctx.db.delete(mp._id);
|
||||||
|
}
|
||||||
|
console.log(` ✅ ${menuPermissoes.length} menu-permissões removidas`);
|
||||||
|
|
||||||
|
// Limpar menu-permissões personalizadas
|
||||||
|
const menuPermissoesPersonalizadas = await ctx.db.query("menuPermissoesPersonalizadas").collect();
|
||||||
|
for (const mpp of menuPermissoesPersonalizadas) {
|
||||||
|
await ctx.db.delete(mpp._id);
|
||||||
|
}
|
||||||
|
console.log(` ✅ ${menuPermissoesPersonalizadas.length} menu-permissões personalizadas removidas`);
|
||||||
|
|
||||||
|
// Limpar role-permissões
|
||||||
|
const rolePermissoes = await ctx.db.query("rolePermissoes").collect();
|
||||||
|
for (const rp of rolePermissoes) {
|
||||||
|
await ctx.db.delete(rp._id);
|
||||||
|
}
|
||||||
|
console.log(` ✅ ${rolePermissoes.length} role-permissões removidas`);
|
||||||
|
|
||||||
|
// Limpar permissões
|
||||||
|
const permissoes = await ctx.db.query("permissoes").collect();
|
||||||
|
for (const permissao of permissoes) {
|
||||||
|
await ctx.db.delete(permissao._id);
|
||||||
|
}
|
||||||
|
console.log(` ✅ ${permissoes.length} permissões removidas`);
|
||||||
|
|
||||||
|
// Limpar roles
|
||||||
|
const roles = await ctx.db.query("roles").collect();
|
||||||
|
for (const role of roles) {
|
||||||
|
await ctx.db.delete(role._id);
|
||||||
|
}
|
||||||
|
console.log(` ✅ ${roles.length} roles removidas`);
|
||||||
|
|
||||||
|
console.log("✨ Banco de dados limpo!");
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
234
packages/backend/convex/solicitacoesAcesso.ts
Normal file
234
packages/backend/convex/solicitacoesAcesso.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import { mutation, query } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
|
||||||
|
// Criar uma nova solicitação de acesso
|
||||||
|
export const create = mutation({
|
||||||
|
args: {
|
||||||
|
nome: v.string(),
|
||||||
|
matricula: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
telefone: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.object({
|
||||||
|
solicitacaoId: v.id("solicitacoesAcesso"),
|
||||||
|
}),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Verificar se já existe uma solicitação pendente com a mesma matrícula
|
||||||
|
const existingByMatricula = await ctx.db
|
||||||
|
.query("solicitacoesAcesso")
|
||||||
|
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
|
||||||
|
.filter((q) => q.eq(q.field("status"), "pendente"))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existingByMatricula) {
|
||||||
|
throw new Error("Já existe uma solicitação pendente para esta matrícula.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se já existe uma solicitação pendente com o mesmo email
|
||||||
|
const existingByEmail = await ctx.db
|
||||||
|
.query("solicitacoesAcesso")
|
||||||
|
.withIndex("by_email", (q) => q.eq("email", args.email))
|
||||||
|
.filter((q) => q.eq(q.field("status"), "pendente"))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existingByEmail) {
|
||||||
|
throw new Error("Já existe uma solicitação pendente para este e-mail.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const solicitacaoId = await ctx.db.insert("solicitacoesAcesso", {
|
||||||
|
nome: args.nome,
|
||||||
|
matricula: args.matricula,
|
||||||
|
email: args.email,
|
||||||
|
telefone: args.telefone,
|
||||||
|
status: "pendente",
|
||||||
|
dataSolicitacao: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { solicitacaoId };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listar todas as solicitações (para o painel administrativo)
|
||||||
|
export const getAll = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id("solicitacoesAcesso"),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
nome: v.string(),
|
||||||
|
matricula: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
telefone: v.string(),
|
||||||
|
status: v.union(
|
||||||
|
v.literal("pendente"),
|
||||||
|
v.literal("aprovado"),
|
||||||
|
v.literal("rejeitado")
|
||||||
|
),
|
||||||
|
dataSolicitacao: v.number(),
|
||||||
|
dataResposta: v.union(v.number(), v.null()),
|
||||||
|
observacoes: v.union(v.string(), v.null()),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const solicitacoes = await ctx.db
|
||||||
|
.query("solicitacoesAcesso")
|
||||||
|
.order("desc")
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
return solicitacoes.map((s) => ({
|
||||||
|
_id: s._id,
|
||||||
|
_creationTime: s._creationTime,
|
||||||
|
nome: s.nome,
|
||||||
|
matricula: s.matricula,
|
||||||
|
email: s.email,
|
||||||
|
telefone: s.telefone,
|
||||||
|
status: s.status,
|
||||||
|
dataSolicitacao: s.dataSolicitacao,
|
||||||
|
dataResposta: s.dataResposta ?? null,
|
||||||
|
observacoes: s.observacoes ?? null,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listar apenas solicitações pendentes
|
||||||
|
export const getPendentes = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id("solicitacoesAcesso"),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
nome: v.string(),
|
||||||
|
matricula: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
telefone: v.string(),
|
||||||
|
status: v.union(
|
||||||
|
v.literal("pendente"),
|
||||||
|
v.literal("aprovado"),
|
||||||
|
v.literal("rejeitado")
|
||||||
|
),
|
||||||
|
dataSolicitacao: v.number(),
|
||||||
|
dataResposta: v.union(v.number(), v.null()),
|
||||||
|
observacoes: v.union(v.string(), v.null()),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const solicitacoes = await ctx.db
|
||||||
|
.query("solicitacoesAcesso")
|
||||||
|
.withIndex("by_status", (q) => q.eq("status", "pendente"))
|
||||||
|
.order("desc")
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
return solicitacoes.map((s) => ({
|
||||||
|
_id: s._id,
|
||||||
|
_creationTime: s._creationTime,
|
||||||
|
nome: s.nome,
|
||||||
|
matricula: s.matricula,
|
||||||
|
email: s.email,
|
||||||
|
telefone: s.telefone,
|
||||||
|
status: s.status,
|
||||||
|
dataSolicitacao: s.dataSolicitacao,
|
||||||
|
dataResposta: s.dataResposta ?? null,
|
||||||
|
observacoes: s.observacoes ?? null,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aprovar uma solicitação
|
||||||
|
export const aprovar = mutation({
|
||||||
|
args: {
|
||||||
|
solicitacaoId: v.id("solicitacoesAcesso"),
|
||||||
|
observacoes: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const solicitacao = await ctx.db.get(args.solicitacaoId);
|
||||||
|
if (!solicitacao) {
|
||||||
|
throw new Error("Solicitação não encontrada.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (solicitacao.status !== "pendente") {
|
||||||
|
throw new Error("Esta solicitação já foi processada.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(args.solicitacaoId, {
|
||||||
|
status: "aprovado",
|
||||||
|
dataResposta: Date.now(),
|
||||||
|
observacoes: args.observacoes,
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rejeitar uma solicitação
|
||||||
|
export const rejeitar = mutation({
|
||||||
|
args: {
|
||||||
|
solicitacaoId: v.id("solicitacoesAcesso"),
|
||||||
|
observacoes: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const solicitacao = await ctx.db.get(args.solicitacaoId);
|
||||||
|
if (!solicitacao) {
|
||||||
|
throw new Error("Solicitação não encontrada.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (solicitacao.status !== "pendente") {
|
||||||
|
throw new Error("Esta solicitação já foi processada.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(args.solicitacaoId, {
|
||||||
|
status: "rejeitado",
|
||||||
|
dataResposta: Date.now(),
|
||||||
|
observacoes: args.observacoes,
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obter uma solicitação por ID
|
||||||
|
export const getById = query({
|
||||||
|
args: {
|
||||||
|
solicitacaoId: v.id("solicitacoesAcesso"),
|
||||||
|
},
|
||||||
|
returns: v.union(
|
||||||
|
v.object({
|
||||||
|
_id: v.id("solicitacoesAcesso"),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
nome: v.string(),
|
||||||
|
matricula: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
telefone: v.string(),
|
||||||
|
status: v.union(
|
||||||
|
v.literal("pendente"),
|
||||||
|
v.literal("aprovado"),
|
||||||
|
v.literal("rejeitado")
|
||||||
|
),
|
||||||
|
dataSolicitacao: v.number(),
|
||||||
|
dataResposta: v.union(v.number(), v.null()),
|
||||||
|
observacoes: v.union(v.string(), v.null()),
|
||||||
|
}),
|
||||||
|
v.null()
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const solicitacao = await ctx.db.get(args.solicitacaoId);
|
||||||
|
if (!solicitacao) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: solicitacao._id,
|
||||||
|
_creationTime: solicitacao._creationTime,
|
||||||
|
nome: solicitacao.nome,
|
||||||
|
matricula: solicitacao.matricula,
|
||||||
|
email: solicitacao.email,
|
||||||
|
telefone: solicitacao.telefone,
|
||||||
|
status: solicitacao.status,
|
||||||
|
dataSolicitacao: solicitacao.dataSolicitacao,
|
||||||
|
dataResposta: solicitacao.dataResposta ?? null,
|
||||||
|
observacoes: solicitacao.observacoes ?? null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
320
packages/backend/convex/usuarios.ts
Normal file
320
packages/backend/convex/usuarios.ts
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import { v } from "convex/values";
|
||||||
|
import { mutation, query } from "./_generated/server";
|
||||||
|
import { hashPassword } from "./auth/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Criar novo usuário (apenas TI)
|
||||||
|
*/
|
||||||
|
export const criar = mutation({
|
||||||
|
args: {
|
||||||
|
matricula: v.string(),
|
||||||
|
nome: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
roleId: v.id("roles"),
|
||||||
|
funcionarioId: v.optional(v.id("funcionarios")),
|
||||||
|
senhaInicial: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.union(
|
||||||
|
v.object({ sucesso: v.literal(true), usuarioId: v.id("usuarios") }),
|
||||||
|
v.object({ sucesso: v.literal(false), erro: v.string() })
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Verificar se matrícula já existe
|
||||||
|
const existente = await ctx.db
|
||||||
|
.query("usuarios")
|
||||||
|
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existente) {
|
||||||
|
return { sucesso: false as const, erro: "Matrícula já cadastrada" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se email já existe
|
||||||
|
const emailExistente = await ctx.db
|
||||||
|
.query("usuarios")
|
||||||
|
.withIndex("by_email", (q) => q.eq("email", args.email))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (emailExistente) {
|
||||||
|
return { sucesso: false as const, erro: "E-mail já cadastrado" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gerar hash da senha inicial
|
||||||
|
const senhaHash = await hashPassword(args.senhaInicial);
|
||||||
|
|
||||||
|
// Criar usuário
|
||||||
|
const usuarioId = await ctx.db.insert("usuarios", {
|
||||||
|
matricula: args.matricula,
|
||||||
|
senhaHash,
|
||||||
|
nome: args.nome,
|
||||||
|
email: args.email,
|
||||||
|
funcionarioId: args.funcionarioId,
|
||||||
|
roleId: args.roleId,
|
||||||
|
ativo: true,
|
||||||
|
primeiroAcesso: true,
|
||||||
|
criadoEm: Date.now(),
|
||||||
|
atualizadoEm: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { sucesso: true as const, usuarioId };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listar todos os usuários com filtros
|
||||||
|
*/
|
||||||
|
export const listar = query({
|
||||||
|
args: {
|
||||||
|
setor: v.optional(v.string()),
|
||||||
|
matricula: v.optional(v.string()),
|
||||||
|
ativo: v.optional(v.boolean()),
|
||||||
|
},
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id("usuarios"),
|
||||||
|
matricula: v.string(),
|
||||||
|
nome: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
ativo: v.boolean(),
|
||||||
|
primeiroAcesso: v.boolean(),
|
||||||
|
ultimoAcesso: v.optional(v.number()),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
role: v.object({
|
||||||
|
_id: v.id("roles"),
|
||||||
|
nome: v.string(),
|
||||||
|
nivel: v.number(),
|
||||||
|
setor: v.optional(v.string()),
|
||||||
|
}),
|
||||||
|
funcionario: v.optional(
|
||||||
|
v.object({
|
||||||
|
_id: v.id("funcionarios"),
|
||||||
|
nome: v.string(),
|
||||||
|
simboloTipo: v.union(
|
||||||
|
v.literal("cargo_comissionado"),
|
||||||
|
v.literal("funcao_gratificada")
|
||||||
|
),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
let usuarios = await ctx.db.query("usuarios").collect();
|
||||||
|
|
||||||
|
// Filtrar por matrícula
|
||||||
|
if (args.matricula) {
|
||||||
|
usuarios = usuarios.filter((u) =>
|
||||||
|
u.matricula.includes(args.matricula!)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrar por ativo
|
||||||
|
if (args.ativo !== undefined) {
|
||||||
|
usuarios = usuarios.filter((u) => u.ativo === args.ativo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar roles e funcionários
|
||||||
|
const resultado = [];
|
||||||
|
for (const usuario of usuarios) {
|
||||||
|
const role = await ctx.db.get(usuario.roleId);
|
||||||
|
if (!role) continue;
|
||||||
|
|
||||||
|
// Filtrar por setor
|
||||||
|
if (args.setor && role.setor !== args.setor) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let funcionario = undefined;
|
||||||
|
if (usuario.funcionarioId) {
|
||||||
|
const func = await ctx.db.get(usuario.funcionarioId);
|
||||||
|
if (func) {
|
||||||
|
funcionario = {
|
||||||
|
_id: func._id,
|
||||||
|
nome: func.nome,
|
||||||
|
simboloTipo: func.simboloTipo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resultado.push({
|
||||||
|
_id: usuario._id,
|
||||||
|
matricula: usuario.matricula,
|
||||||
|
nome: usuario.nome,
|
||||||
|
email: usuario.email,
|
||||||
|
ativo: usuario.ativo,
|
||||||
|
primeiroAcesso: usuario.primeiroAcesso,
|
||||||
|
ultimoAcesso: usuario.ultimoAcesso,
|
||||||
|
criadoEm: usuario.criadoEm,
|
||||||
|
role: {
|
||||||
|
_id: role._id,
|
||||||
|
nome: role.nome,
|
||||||
|
nivel: role.nivel,
|
||||||
|
setor: role.setor,
|
||||||
|
},
|
||||||
|
funcionario,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultado;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ativar/Desativar usuário
|
||||||
|
*/
|
||||||
|
export const alterarStatus = mutation({
|
||||||
|
args: {
|
||||||
|
usuarioId: v.id("usuarios"),
|
||||||
|
ativo: v.boolean(),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await ctx.db.patch(args.usuarioId, {
|
||||||
|
ativo: args.ativo,
|
||||||
|
atualizadoEm: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Se desativar, desativar todas as sessões
|
||||||
|
if (!args.ativo) {
|
||||||
|
const sessoes = await ctx.db
|
||||||
|
.query("sessoes")
|
||||||
|
.withIndex("by_usuario", (q) => q.eq("usuarioId", args.usuarioId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (const sessao of sessoes) {
|
||||||
|
await ctx.db.patch(sessao._id, { ativo: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resetar senha do usuário
|
||||||
|
*/
|
||||||
|
export const resetarSenha = mutation({
|
||||||
|
args: {
|
||||||
|
usuarioId: v.id("usuarios"),
|
||||||
|
novaSenha: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const senhaHash = await hashPassword(args.novaSenha);
|
||||||
|
|
||||||
|
await ctx.db.patch(args.usuarioId, {
|
||||||
|
senhaHash,
|
||||||
|
primeiroAcesso: true,
|
||||||
|
atualizadoEm: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Desativar todas as sessões
|
||||||
|
const sessoes = await ctx.db
|
||||||
|
.query("sessoes")
|
||||||
|
.withIndex("by_usuario", (q) => q.eq("usuarioId", args.usuarioId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (const sessao of sessoes) {
|
||||||
|
await ctx.db.patch(sessao._id, { ativo: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Excluir usuário
|
||||||
|
*/
|
||||||
|
export const excluir = mutation({
|
||||||
|
args: {
|
||||||
|
usuarioId: v.id("usuarios"),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Excluir sessões
|
||||||
|
const sessoes = await ctx.db
|
||||||
|
.query("sessoes")
|
||||||
|
.withIndex("by_usuario", (q) => q.eq("usuarioId", args.usuarioId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (const sessao of sessoes) {
|
||||||
|
await ctx.db.delete(sessao._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Excluir usuário
|
||||||
|
await ctx.db.delete(args.usuarioId);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ativar usuário
|
||||||
|
*/
|
||||||
|
export const ativar = mutation({
|
||||||
|
args: {
|
||||||
|
id: v.id("usuarios"),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await ctx.db.patch(args.id, {
|
||||||
|
ativo: true,
|
||||||
|
atualizadoEm: Date.now(),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desativar usuário
|
||||||
|
*/
|
||||||
|
export const desativar = mutation({
|
||||||
|
args: {
|
||||||
|
id: v.id("usuarios"),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await ctx.db.patch(args.id, {
|
||||||
|
ativo: false,
|
||||||
|
atualizadoEm: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Desativar todas as sessões
|
||||||
|
const sessoes = await ctx.db
|
||||||
|
.query("sessoes")
|
||||||
|
.withIndex("by_usuario", (q) => q.eq("usuarioId", args.id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (const sessao of sessoes) {
|
||||||
|
await ctx.db.patch(sessao._id, { ativo: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alterar role de um usuário
|
||||||
|
*/
|
||||||
|
export const alterarRole = mutation({
|
||||||
|
args: {
|
||||||
|
usuarioId: v.id("usuarios"),
|
||||||
|
novaRoleId: v.id("roles"),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Verificar se a role existe
|
||||||
|
const role = await ctx.db.get(args.novaRoleId);
|
||||||
|
if (!role) {
|
||||||
|
throw new Error("Role não encontrada");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualizar usuário
|
||||||
|
await ctx.db.patch(args.usuarioId, {
|
||||||
|
roleId: args.novaRoleId,
|
||||||
|
atualizadoEm: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
12
packages/backend/start-local.ps1
Normal file
12
packages/backend/start-local.ps1
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Script para iniciar o Convex Local
|
||||||
|
Write-Host "🚀 Iniciando Convex Local..." -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "📍 O Convex estará disponível em: http://localhost:3210" -ForegroundColor Cyan
|
||||||
|
Write-Host "💾 Os dados serão armazenados em: .convex/local_storage" -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "⚠️ Para parar o servidor, pressione Ctrl+C" -ForegroundColor Yellow
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Iniciar o Convex em modo local
|
||||||
|
bunx convex dev --run-local
|
||||||
|
|
||||||
Reference in New Issue
Block a user