Merge pull request #15 from killer-cf/fix-page-with-lint-errors

Fix page with lint errors
This commit is contained in:
Kilder Costa
2025-11-12 12:00:40 -03:00
committed by GitHub
10 changed files with 1758 additions and 1714 deletions

19
.cursor/mcp.json Normal file
View File

@@ -0,0 +1,19 @@
{
"mcpServers": {
"svelte": {
"url": "https://mcp.svelte.dev/mcp"
},
"context7": {
"url": "https://mcp.context7.com/mcp"
},
"convex": {
"command": "npx",
"args": [
"-y",
"convex@latest",
"mcp",
"start"
]
}
}
}

View File

@@ -0,0 +1,27 @@
---
alwaysApply: true
---
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
## Available MCP Tools:
### 1. list-sections
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths.
When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections.
### 2. get-documentation
Retrieves full documentation content for specific sections. Accepts single or multiple sections.
After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task.
### 3. svelte-autofixer
Analyzes Svelte code and returns issues and suggestions.
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
### 4. playground-link
Generates a Svelte Playground link with the provided code.
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.

View File

@@ -1,6 +1,7 @@
---
description: Guidelines for TypeScript usage, including type safety rules and Convex query typing
globs: **/*.ts,**/*.tsx,**/*.svelte
globs: **/*.ts,**/*.svelte
alwaysApply: false
---
# TypeScript Guidelines
@@ -8,6 +9,7 @@ globs: **/*.ts,**/*.tsx,**/*.svelte
## Type Safety Rules
### Avoid `any` Type
- **NEVER** use the `any` type in production code
- The only exception is in test files (files matching `*.test.ts`, `*.test.tsx`, `*.spec.ts`, `*.spec.tsx`)
- Instead of `any`, use:
@@ -20,6 +22,7 @@ globs: **/*.ts,**/*.tsx,**/*.svelte
### Examples
**❌ Bad:**
```typescript
function processData(data: any) {
return data.value;
@@ -27,6 +30,7 @@ function processData(data: any) {
```
**✅ Good:**
```typescript
function processData(data: { value: string }) {
return data.value;
@@ -47,6 +51,7 @@ function processData(data: unknown) {
```
**✅ Exception (tests only):**
```typescript
// test.ts or *.spec.ts
it('should handle any input', () => {
@@ -58,6 +63,7 @@ it('should handle any input', () => {
## Convex Query Typing
### Frontend Query Usage
- **DO NOT** create manual type definitions for Convex query results in the frontend
- Convex queries already return properly typed results based on their `returns` validator
- The TypeScript types are automatically inferred from the query's return validator
@@ -66,10 +72,11 @@ it('should handle any input', () => {
### Examples
**❌ Bad:**
```typescript
// Don't manually type the result
type UserListResult = Array<{
_id: Id<"users">;
_id: Id<'users'>;
name: string;
}>;
@@ -77,6 +84,7 @@ const users: UserListResult = useQuery(api.users.list);
```
**✅ Good:**
```typescript
// Let TypeScript infer the type from the query
const users = useQuery(api.users.list);
@@ -84,7 +92,7 @@ const users = useQuery(api.users.list);
// You can still use it with type inference
if (users !== undefined) {
users.forEach(user => {
users.forEach((user) => {
// TypeScript knows user._id is Id<"users"> and user.name is string
console.log(user.name);
});
@@ -92,16 +100,18 @@ if (users !== undefined) {
```
**✅ Good (with explicit type if needed for clarity):**
```typescript
// Only if you need to export or explicitly annotate for documentation
import type { FunctionReturnType } from "convex/server";
import type { api } from "./convex/_generated/api";
import type { FunctionReturnType } from 'convex/server';
import type { api } from './convex/_generated/api';
type UserListResult = FunctionReturnType<typeof api.users.list>;
const users = useQuery(api.users.list);
```
### Best Practices
- Trust Convex's type inference - it's based on your schema and validators
- If you need type annotations, use `FunctionReturnType` from Convex's type utilities
- Only create manual types if you're doing complex transformations that need intermediate types

View File

@@ -1,20 +1,10 @@
<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";
type RoleRow = {
_id: Id<"roles">;
_creationTime: number;
nome: string;
descricao: string;
nivel: number;
setor?: string;
customizado: boolean;
editavel?: boolean;
criadoPor?: Id<"usuarios">;
};
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
const client = useConvexClient();
@@ -23,49 +13,43 @@
const catalogoQuery = useQuery(api.permissoesAcoes.listarRecursosEAcoes, {});
let salvando = $state(false);
let mensagem = $state<{ tipo: "success" | "error"; texto: string } | null>(
null,
);
let busca = $state("");
let filtroRole = $state("");
let mensagem = $state<{ tipo: 'success' | 'error'; texto: string } | null>(null);
let busca = $state('');
let filtroRole = $state<Id<'roles'> | ''>('');
let modalNovoPerfilAberto = $state(false);
let nomeNovoPerfil = $state('');
let descricaoNovoPerfil = $state('');
let setorNovoPerfil = $state('');
let nivelNovoPerfil = $state(3);
let roleParaDuplicar = $state<Id<'roles'> | ''>('');
let criandoNovoPerfil = $state(false);
// Controla quais recursos estão expandidos (mostrando as ações) por perfil
// Formato: { "roleId-recurso": true/false }
let recursosExpandidos: Record<string, boolean> = $state({});
// Gerenciamento de Perfis
let modalGerenciarPerfisAberto = $state(false);
let perfilSendoEditado = $state<RoleRow | null>(null);
let nomeNovoPerfil = $state("");
let descricaoNovoPerfil = $state("");
let nivelNovoPerfil = $state(3);
let processando = $state(false);
// Cache de permissões por role
let permissoesPorRole: Record<
string,
Array<{ recurso: string; acoes: Array<string> }>
> = $state({});
async function carregarPermissoesRole(roleId: Id<"roles">) {
if (permissoesPorRole[roleId]) return;
const dados = await client.query(
api.permissoesAcoes.listarPermissoesAcoesPorRole,
{ roleId },
let permissoesPorRole: Record<string, Array<{ recurso: string; acoes: Array<string> }>> = $state(
{}
);
async function carregarPermissoesRole(roleId: Id<'roles'>) {
if (permissoesPorRole[roleId]) return;
const dados = await client.query(api.permissoesAcoes.listarPermissoesAcoesPorRole, { roleId });
permissoesPorRole[roleId] = dados;
}
function toggleRecurso(roleId: Id<"roles">, recurso: string) {
function toggleRecurso(roleId: Id<'roles'>, recurso: string) {
const key = `${roleId}-${recurso}`;
recursosExpandidos[key] = !recursosExpandidos[key];
}
function isRecursoExpandido(roleId: Id<"roles">, recurso: string) {
function isRecursoExpandido(roleId: Id<'roles'>, recurso: string) {
const key = `${roleId}-${recurso}`;
return recursosExpandidos[key] ?? false;
}
function mostrarMensagem(tipo: "success" | "error", texto: string) {
function mostrarMensagem(tipo: 'success' | 'error', texto: string) {
mensagem = { tipo, texto };
setTimeout(() => {
mensagem = null;
@@ -79,9 +63,7 @@
if (busca.trim()) {
const b = busca.toLowerCase();
rs = rs.filter(
(r) =>
r.descricao.toLowerCase().includes(b) ||
r.nome.toLowerCase().includes(b),
(r) => r.descricao.toLowerCase().includes(b) || r.nome.toLowerCase().includes(b)
);
}
return rs;
@@ -98,143 +80,142 @@
}
});
async function toggleAcao(
roleId: Id<"roles">,
recurso: string,
acao: string,
conceder: boolean,
) {
async function toggleAcao(roleId: Id<'roles'>, recurso: string, acao: string, conceder: boolean) {
try {
salvando = true;
await client.mutation(api.permissoesAcoes.atualizarPermissaoAcao, {
roleId,
recurso,
acao,
conceder,
conceder
});
// Atualizar cache local
const atual = permissoesPorRole[roleId] || [];
const entry = atual.find((e) => e.recurso === recurso);
if (entry) {
const set = new Set(entry.acoes);
if (conceder) set.add(acao);
else set.delete(acao);
entry.acoes = Array.from(set);
} else {
permissoesPorRole[roleId] = [
...atual,
{ recurso, acoes: conceder ? [acao] : [] },
];
entry.acoes = conceder
? [...entry.acoes.filter((valor) => valor !== acao), acao]
: entry.acoes.filter((valor) => valor !== acao);
} else if (conceder) {
permissoesPorRole[roleId] = [...atual, { recurso, acoes: [acao] }];
}
mostrarMensagem("success", "Permissão atualizada com sucesso!");
mostrarMensagem('success', 'Permissão atualizada com sucesso!');
} catch (error: unknown) {
// Changed to unknown
const message =
error instanceof Error ? error.message : "Erro ao atualizar permissão";
mostrarMensagem("error", message);
const message = error instanceof Error ? error.message : 'Erro ao atualizar permissão';
mostrarMensagem('error', message);
} finally {
salvando = false;
}
}
function isConcedida(roleId: Id<"roles">, recurso: string, acao: string) {
function isConcedida(roleId: Id<'roles'>, recurso: string, acao: string) {
const dados = permissoesPorRole[roleId];
const entry = dados?.find((e) => e.recurso === recurso);
return entry ? entry.acoes.includes(acao) : false;
}
function abrirModalCriarPerfil() {
nomeNovoPerfil = "";
descricaoNovoPerfil = "";
nivelNovoPerfil = 3; // Default to a common level
perfilSendoEditado = null;
modalGerenciarPerfisAberto = true;
const gerarIdentificador = (valor: string) =>
valor
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.replace(/_{2,}/g, '_');
const identificadorSugerido = $derived.by(() => gerarIdentificador(nomeNovoPerfil));
const podeSalvarNovoPerfil = $derived.by(() => {
const nome = nomeNovoPerfil.trim();
const nivel = Number(nivelNovoPerfil);
const nivelValido = Number.isFinite(nivel) && nivel >= 0 && nivel <= 10;
return nome.length >= 3 && nivelValido && !criandoNovoPerfil;
});
const roleDuplicacaoSelecionada = $derived.by(() => {
if (!roleParaDuplicar || !rolesQuery.data) return null;
return rolesQuery.data.find((role) => role._id === roleParaDuplicar) ?? null;
});
$effect(() => {
if (roleParaDuplicar) {
carregarPermissoesRole(roleParaDuplicar);
}
});
const resumoPermissoesDuplicacao = $derived.by(() => {
if (!roleParaDuplicar) return null;
const permissoes = permissoesPorRole[roleParaDuplicar];
if (!permissoes) return null;
const totalRecursos = permissoes.length;
const totalAcoes = permissoes.reduce((acc, item) => acc + item.acoes.length, 0);
return { totalRecursos, totalAcoes };
});
function abrirModalNovoPerfil() {
nomeNovoPerfil = '';
descricaoNovoPerfil = '';
setorNovoPerfil = '';
nivelNovoPerfil = 3;
roleParaDuplicar = '';
modalNovoPerfilAberto = true;
}
function prepararEdicaoPerfil(role: RoleRow) {
perfilSendoEditado = role;
nomeNovoPerfil = role.nome;
descricaoNovoPerfil = role.descricao;
nivelNovoPerfil = role.nivel;
modalGerenciarPerfisAberto = true;
}
function fecharModalGerenciarPerfis() {
modalGerenciarPerfisAberto = false;
perfilSendoEditado = null;
function fecharModalNovoPerfil() {
modalNovoPerfilAberto = false;
}
async function criarNovoPerfil() {
if (!nomeNovoPerfil.trim()) return;
if (!podeSalvarNovoPerfil) return;
processando = true;
const nome = nomeNovoPerfil.trim();
const descricao = descricaoNovoPerfil.trim();
const setor = setorNovoPerfil.trim();
const nivel = Math.min(Math.max(Math.round(Number(nivelNovoPerfil)), 0), 10);
criandoNovoPerfil = true;
try {
const result = await client.mutation(api.roles.criar, {
nome: nomeNovoPerfil.trim(),
descricao: descricaoNovoPerfil.trim(),
nivel: nivelNovoPerfil,
customizado: true,
const resultado = await client.mutation(api.roles.criar, {
nome,
descricao,
nivel,
setor: setor.length > 0 ? setor : undefined,
copiarDeRoleId: roleParaDuplicar || undefined
});
if (result.sucesso) {
mostrarMensagem("success", "Perfil criado com sucesso!");
nomeNovoPerfil = "";
descricaoNovoPerfil = "";
nivelNovoPerfil = 3;
fecharModalGerenciarPerfis();
if (rolesQuery.refetch) {
// Verificação para garantir que refetch existe
rolesQuery.refetch(); // Atualiza a lista de perfis
}
if (resultado.sucesso) {
mostrarMensagem('success', 'Perfil criado com sucesso!');
modalNovoPerfilAberto = false;
} else {
mostrarMensagem("error", `Erro ao criar perfil: ${result.erro}`);
const mapaErros: Record<string, string> = {
nome_ja_utilizado:
'Já existe um perfil com este identificador. Ajuste o nome e tente novamente.',
nome_invalido: 'Informe um nome válido com pelo menos 3 caracteres.',
sem_permissao: 'Você não possui permissão para criar novos perfis.',
role_origem_nao_encontrada: 'Não foi possível carregar o perfil escolhido como base.'
};
const mensagemErro =
mapaErros[resultado.erro] ?? 'Não foi possível criar o perfil. Tente novamente.';
mostrarMensagem('error', mensagemErro);
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
mostrarMensagem("error", `Erro ao criar perfil: ${message}`);
const mensagemErro =
error instanceof Error ? error.message : 'Erro inesperado ao criar o perfil.';
mostrarMensagem('error', mensagemErro);
} finally {
processando = false;
}
}
async function editarPerfil() {
if (!perfilSendoEditado || !nomeNovoPerfil.trim()) return;
processando = true;
try {
const result = await client.mutation(api.roles.atualizar, {
roleId: perfilSendoEditado._id,
nome: nomeNovoPerfil.trim(),
descricao: descricaoNovoPerfil.trim(),
nivel: nivelNovoPerfil,
setor: perfilSendoEditado.setor, // Manter setor existente
});
if (result.sucesso) {
mostrarMensagem("success", "Perfil atualizado com sucesso!");
fecharModalGerenciarPerfis();
if (rolesQuery.refetch) {
// Verificação para garantir que refetch existe
rolesQuery.refetch(); // Atualiza a lista de perfis
}
} else {
mostrarMensagem("error", `Erro ao atualizar perfil: ${result.erro}`);
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
mostrarMensagem("error", `Erro ao atualizar perfil: ${message}`);
} finally {
processando = false;
criandoNovoPerfil = false;
}
}
</script>
<ProtectedRoute allowedRoles={["ti_master", "admin"]} maxLevel={1}>
<ProtectedRoute allowedRoles={['ti_master', 'admin']} maxLevel={1}>
<!-- Breadcrumb -->
<div class="text-sm breadcrumbs mb-4">
<div class="breadcrumbs mb-4 text-sm">
<ul>
<li>
<a href="/" class="text-primary hover:text-primary-focus">
<a href={resolve('/')} class="text-primary hover:text-primary-focus">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
@@ -253,7 +234,7 @@
</a>
</li>
<li>
<a href="/ti" class="text-primary hover:text-primary-focus">TI</a>
<a href={resolve('/ti')} class="text-primary hover:text-primary-focus">TI</a>
</li>
<li class="font-semibold">Gerenciar Perfis & Permissões</li>
</ul>
@@ -261,11 +242,11 @@
<!-- Header -->
<div class="mb-6">
<div class="flex items-center gap-3 mb-2">
<div class="p-3 bg-primary/10 rounded-xl">
<div class="mb-2 flex flex-wrap items-center gap-3">
<div class="bg-primary/10 rounded-xl p-3">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-primary"
class="text-primary h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -279,14 +260,14 @@
</svg>
</div>
<div class="flex-1">
<h1 class="text-3xl font-bold text-base-content">
<h1 class="text-base-content text-3xl font-bold">
Gerenciar Perfis & Permissões de Acesso
</h1>
<p class="text-base-content/60 mt-1">
Configure as permissões de acesso aos menus do sistema por função
</p>
</div>
<button class="btn btn-primary gap-2" onclick={abrirModalCriarPerfil}>
<button class="btn btn-primary gap-2" onclick={abrirModalNovoPerfil}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
@@ -301,9 +282,9 @@
d="M12 4v16m8-8H4"
/>
</svg>
Criar Novo Perfil
Criar novo perfil
</button>
<button class="btn btn-ghost gap-2" onclick={() => goto("/ti")}>
<button class="btn btn-ghost gap-2" onclick={() => goto(resolve('/ti'))}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
@@ -327,13 +308,13 @@
{#if mensagem}
<div
class="alert mb-6 shadow-lg"
class:alert-success={mensagem.tipo === "success"}
class:alert-error={mensagem.tipo === "error"}
class:alert-success={mensagem.tipo === 'success'}
class:alert-error={mensagem.tipo === 'error'}
>
{#if mensagem.tipo === "success"}
{#if mensagem.tipo === 'success'}
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
@@ -347,7 +328,7 @@
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
@@ -364,9 +345,9 @@
{/if}
<!-- Filtros e Busca -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Busca por menu -->
<div class="form-control">
<label class="label" for="busca">
@@ -382,7 +363,7 @@
/>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 absolute right-3 top-3.5 text-base-content/40"
class="text-base-content/40 absolute top-3.5 right-3 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -402,14 +383,10 @@
<label class="label" for="filtroRole">
<span class="label-text font-semibold">Filtrar por Perfil</span>
</label>
<select
id="filtroRole"
class="select select-bordered w-full"
bind:value={filtroRole}
>
<select id="filtroRole" class="select select-bordered w-full" bind:value={filtroRole}>
<option value="">Todos os perfis</option>
{#if rolesQuery.data}
{#each rolesQuery.data as roleRow}
{#each rolesQuery.data as roleRow (roleRow._id)}
<option value={roleRow._id}>
{roleRow.descricao} ({roleRow.nome})
</option>
@@ -420,14 +397,14 @@
</div>
{#if busca || filtroRole}
<div class="flex items-center gap-2 mt-2">
<span class="text-sm text-base-content/60">Filtros ativos:</span>
<div class="mt-2 flex items-center gap-2">
<span class="text-base-content/60 text-sm">Filtros ativos:</span>
{#if busca}
<div class="badge badge-primary gap-2">
Busca: {busca}
<button
class="btn btn-ghost btn-xs"
onclick={() => (busca = "")}
onclick={() => (busca = '')}
aria-label="Limpar busca"
>
@@ -439,7 +416,7 @@
Perfil filtrado
<button
class="btn btn-ghost btn-xs"
onclick={() => (filtroRole = "")}
onclick={() => (filtroRole = '')}
aria-label="Limpar filtro"
>
@@ -451,60 +428,16 @@
</div>
</div>
<!-- Informações sobre o sistema de permissões -->
<div class="alert alert-info mb-6 shadow-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<h3 class="font-bold text-lg">Como funciona o sistema de permissões:</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<div>
<h4 class="font-semibold text-sm">Tipos de Permissão:</h4>
<ul class="text-sm mt-1 space-y-1">
<li>
<strong>Acessar:</strong> Visualizar menu e acessar página
</li>
<li><strong>Consultar:</strong> Ver dados (requer "Acessar")</li>
<li>
<strong>Gravar:</strong> Criar/editar/excluir (requer "Consultar")
</li>
</ul>
</div>
<div>
<h4 class="font-semibold text-sm">Perfis Especiais:</h4>
<ul class="text-sm mt-1 space-y-1">
<li><strong>Admin e TI:</strong> Acesso total automático</li>
<li><strong>Dashboard:</strong> Público para todos</li>
<li>
<strong>Perfil Customizado:</strong> Permissões personalizadas
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Matriz de Permissões por Ação -->
{#if rolesQuery.isLoading || catalogoQuery.isLoading}
<div class="flex justify-center items-center py-12">
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if rolesQuery.error}
<div class="alert alert-error">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
@@ -523,7 +456,7 @@
<div class="card-body items-center text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 text-base-content/30"
class="text-base-content/30 h-16 w-16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -535,17 +468,17 @@
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<h3 class="text-xl font-bold mt-4">Nenhum resultado encontrado</h3>
<h3 class="mt-4 text-xl font-bold">Nenhum resultado encontrado</h3>
<p class="text-base-content/60">
{busca
? `Não foram encontrados perfis com "${busca}"`
: "Nenhum perfil corresponde aos filtros aplicados"}
: 'Nenhum perfil corresponde aos filtros aplicados'}
</p>
<button
class="btn btn-primary btn-sm mt-4"
onclick={() => {
busca = "";
filtroRole = "";
busca = '';
filtroRole = '';
}}
>
Limpar Filtros
@@ -554,13 +487,13 @@
</div>
{/if}
{#each rolesFiltradas as roleRow}
{#each rolesFiltradas as roleRow (roleRow._id)}
{@const roleId = roleRow._id}
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body">
<div class="flex items-center justify-between mb-4 flex-wrap gap-4">
<div class="flex-1 min-w-[200px]">
<div class="flex items-center gap-3 mb-2">
<div class="mb-4 flex flex-wrap items-center gap-4">
<div class="min-w-[200px] flex-1">
<div class="mb-2 flex items-center gap-3">
<h2 class="card-title text-2xl">{roleRow.descricao}</h2>
<div class="badge badge-lg badge-primary">
Nível {roleRow.nivel}
@@ -585,28 +518,17 @@
</div>
{/if}
</div>
<p class="text-sm text-base-content/60">
<span class="font-mono bg-base-200 px-2 py-1 rounded"
>{roleRow.nome}</span
>
<p class="text-base-content/60 text-sm">
<span class="bg-base-200 rounded px-2 py-1 font-mono">{roleRow.nome}</span>
</p>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-outline btn-sm"
onclick={() => prepararEdicaoPerfil(roleRow)}
>
Editar
</button>
</div>
</div>
{#if roleRow.nivel <= 1}
<div class="alert alert-success shadow-md">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
@@ -620,27 +542,24 @@
<div>
<h3 class="font-bold">Perfil Administrativo</h3>
<div class="text-sm">
Este perfil possui acesso total ao sistema automaticamente,
sem necessidade de configuração manual.
Este perfil possui acesso total ao sistema automaticamente, sem necessidade de
configuração manual.
</div>
</div>
</div>
{:else if catalogoQuery.data}
<div class="space-y-2">
{#each catalogoQuery.data as item}
{@const recursoExpandido = isRecursoExpandido(
roleId,
item.recurso,
)}
<div class="border border-base-300 rounded-lg overflow-hidden">
{#each catalogoQuery.data as item (item.recurso)}
{@const recursoExpandido = isRecursoExpandido(roleId, item.recurso)}
<div class="border-base-300 overflow-hidden rounded-lg border">
<!-- Cabeçalho do recurso (clicável) -->
<button
type="button"
class="w-full px-4 py-3 bg-base-200 hover:bg-base-300 transition-colors flex items-center justify-between"
class="bg-base-200 hover:bg-base-300 flex w-full items-center justify-between px-4 py-3 transition-colors"
onclick={() => toggleRecurso(roleId, item.recurso)}
disabled={salvando}
>
<span class="font-semibold text-lg">{item.recurso}</span>
<span class="text-lg font-semibold">{item.recurso}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 transition-transform"
@@ -660,11 +579,11 @@
<!-- Lista de ações (visível quando expandido) -->
{#if recursoExpandido}
<div class="px-4 py-3 bg-base-100 border-t border-base-300">
<div class="bg-base-100 border-base-300 border-t px-4 py-3">
<div class="space-y-2">
{#each ["ver", "listar", "criar", "editar", "excluir"] as acao}
{#each ['ver', 'listar', 'criar', 'editar', 'excluir'] as acao (acao)}
<label
class="flex items-center gap-3 cursor-pointer hover:bg-base-200 p-2 rounded transition-colors"
class="hover:bg-base-200 flex cursor-pointer items-center gap-3 rounded p-2 transition-colors"
>
<input
type="checkbox"
@@ -672,16 +591,9 @@
checked={isConcedida(roleId, item.recurso, acao)}
disabled={salvando}
onchange={(e) =>
toggleAcao(
roleId,
item.recurso,
acao,
e.currentTarget.checked,
)}
toggleAcao(roleId, item.recurso, acao, e.currentTarget.checked)}
/>
<span class="flex-1 capitalize font-medium"
>{acao}</span
>
<span class="flex-1 font-medium capitalize">{acao}</span>
</label>
{/each}
</div>
@@ -696,140 +608,176 @@
{/each}
{/if}
<!-- Modal Gerenciar Perfis -->
{#if modalGerenciarPerfisAberto}
{#if modalNovoPerfilAberto}
<dialog class="modal modal-open">
<div
class="modal-box max-w-4xl w-full overflow-hidden border border-base-200/60 bg-base-200/40 p-0 shadow-2xl"
class="modal-box bg-base-100 w-full max-w-4xl overflow-hidden rounded-2xl p-0 shadow-2xl"
>
<div
class="relative bg-linear-to-r from-primary via-primary/90 to-secondary/80 px-8 py-6 text-base-100"
class="from-primary via-primary/85 to-secondary/80 text-base-100 relative bg-linear-to-r px-8 py-6"
>
<button
type="button"
class="btn btn-sm btn-circle btn-ghost absolute right-4 top-4 text-base-100/80 hover:text-base-100"
onclick={fecharModalGerenciarPerfis}
aria-label="Fechar modal"
class="btn btn-circle btn-ghost btn-sm text-base-100/80 hover:text-base-100 absolute top-4 right-4"
onclick={fecharModalNovoPerfil}
aria-label="Fechar"
>
</button>
<h3 class="text-3xl font-black tracking-tight">
Gerenciar Perfis de Acesso
</h3>
<p class="mt-2 max-w-2xl text-sm text-base-100/80 md:text-base">
{perfilSendoEditado
? "Atualize as informações do perfil selecionado para manter a governança de acesso alinhada com as diretrizes do sistema."
: "Crie um novo perfil de acesso definindo nome, descrição e nível hierárquico conforme os padrões adotados pela Secretaria."}
<div class="space-y-2 pr-10">
<h3 class="text-3xl font-black tracking-tight">Criar novo perfil de acesso</h3>
<p class="text-base-100/80 max-w-2xl text-sm md:text-base">
Defina as informações do perfil e, se desejar, reutilize as permissões de um perfil
existente para acelerar a configuração.
</p>
</div>
<div class="bg-base-100 px-8 py-6">
<section
class="space-y-6 rounded-2xl border border-base-200/60 bg-base-100 p-6 shadow-sm"
>
<div
class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"
>
<div class="space-y-1">
<h4 class="text-xl font-semibold text-base-content">
{perfilSendoEditado ? "Editar Perfil" : "Criar Novo Perfil"}
</h4>
<p class="text-sm text-base-content/70">
{perfilSendoEditado
? "Os campos bloqueados indicam atributos padronizados do sistema. Ajuste apenas o que estiver disponível."
: "Preencha as informações com atenção para garantir que o novo perfil siga o padrão institucional."}
</p>
</div>
{#if perfilSendoEditado}
{#if identificadorSugerido}
<div class="text-base-100/80 flex flex-wrap items-center gap-2 text-xs">
<span
class="badge badge-outline badge-primary badge-lg self-start flex flex-col items-center gap-1 px-5 py-3 text-center"
class="badge badge-outline badge-sm border-base-100/40 tracking-widest uppercase"
>
<span
class="text-[11px] font-semibold uppercase tracking-[0.32em] text-primary"
>
Nível atual
</span>
<span class="text-2xl font-bold leading-none text-primary">
{perfilSendoEditado.nivel}
</span>
Identificador
</span>
<span class="font-mono text-sm">{identificadorSugerido}</span>
</div>
{/if}
</div>
</div>
<div class="space-y-6 px-8 py-6">
<div class="grid gap-6 md:grid-cols-2">
<div class="form-control md:col-span-2">
<label class="label" for="nome-perfil-input">
<span class="label-text">Nome do Perfil *</span>
<label class="label" for="nome-novo-perfil">
<span class="label-text font-semibold">Nome do perfil *</span>
</label>
<input
id="nome-perfil-input"
id="nome-novo-perfil"
type="text"
bind:value={nomeNovoPerfil}
class="input input-bordered input-primary"
placeholder="Ex: RH, Financeiro, Gestor"
disabled={perfilSendoEditado !== null &&
!perfilSendoEditado.customizado}
placeholder="Ex.: Financeiro, RH, Supervisão de Campo"
bind:value={nomeNovoPerfil}
maxlength={60}
/>
</div>
<div class="form-control md:col-span-2">
<label class="label" for="descricao-perfil-input">
<span class="label-text">Descrição</span>
<label class="label" for="descricao-novo-perfil">
<span class="label-text font-semibold">Descrição</span>
</label>
<textarea
id="descricao-perfil-input"
id="descricao-novo-perfil"
class="textarea textarea-bordered textarea-primary min-h-[96px]"
placeholder="Explique como este perfil será utilizado e quais áreas ele atende."
bind:value={descricaoNovoPerfil}
class="textarea textarea-bordered textarea-primary min-h-[120px]"
placeholder="Breve descrição das responsabilidades e limites de atuação deste perfil."
maxlength={240}
></textarea>
</div>
<div class="form-control max-w-xs">
<label class="label" for="nivel-perfil-input">
<span class="label-text">Nível de Acesso (0-5) *</span>
<div class="form-control">
<label class="label" for="setor-novo-perfil">
<span class="label-text font-semibold">Setor (opcional)</span>
</label>
<input
id="nivel-perfil-input"
type="number"
bind:value={nivelNovoPerfil}
min="0"
max="5"
class="input input-bordered input-secondary"
disabled={perfilSendoEditado !== null &&
!perfilSendoEditado.customizado}
id="setor-novo-perfil"
type="text"
class="input input-bordered"
placeholder="Informe o setor ou departamento responsável"
bind:value={setorNovoPerfil}
maxlength={40}
/>
</div>
</div>
</section>
<div
class="mt-8 flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between"
<div class="form-control">
<label class="label" for="nivel-novo-perfil">
<span class="label-text font-semibold">Nível de acesso (0 a 10)</span>
</label>
<input
id="nivel-novo-perfil"
type="number"
class="input input-bordered"
min={0}
max={10}
step={1}
bind:value={nivelNovoPerfil}
/>
<span class="text-base-content/60 mt-1 text-xs">
Níveis menores representam maior privilégio (ex.: 0 = administrativo).
</span>
</div>
<div class="form-control md:col-span-2">
<label class="label" for="duplicar-novo-perfil">
<span class="label-text font-semibold">Duplicar permissões de</span>
</label>
<select
id="duplicar-novo-perfil"
class="select select-bordered"
bind:value={roleParaDuplicar}
>
<p class="text-sm text-base-content/60">
Campos marcados com * são obrigatórios.
<option value="">Iniciar perfil vazio</option>
{#if rolesQuery.data}
{#each rolesQuery.data as roleDuplicavel (roleDuplicavel._id)}
<option value={roleDuplicavel._id}>
{roleDuplicavel.descricao} nível {roleDuplicavel.nivel}
</option>
{/each}
{/if}
</select>
</div>
</div>
{#if roleDuplicacaoSelecionada}
<div class="border-base-200 bg-base-100/80 rounded-2xl border p-4">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<p class="text-base-content font-semibold">
Permissões a serem copiadas de <span class="font-bold"
>{roleDuplicacaoSelecionada.descricao}</span
>
</p>
<p class="text-base-content/70 text-sm">
As permissões atuais serão reaproveitadas automaticamente para o novo perfil.
</p>
</div>
{#if resumoPermissoesDuplicacao}
<div class="flex items-center gap-4 text-sm">
<span class="badge badge-outline badge-sm">
{resumoPermissoesDuplicacao.totalRecursos} recursos
</span>
<span class="badge badge-outline badge-sm">
{resumoPermissoesDuplicacao.totalAcoes} ações
</span>
</div>
{:else}
<span class="loading loading-dots loading-sm text-primary"></span>
{/if}
</div>
</div>
{/if}
<div class="flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-base-content/60 text-xs">
Campos marcados com * são obrigatórios. As alterações entram em vigor imediatamente
após a criação.
</p>
<div class="flex flex-wrap gap-3">
<button
type="button"
class="btn btn-ghost"
onclick={fecharModalGerenciarPerfis}
disabled={processando}
onclick={fecharModalNovoPerfil}
disabled={criandoNovoPerfil}
>
Cancelar
</button>
<button
type="button"
class="btn btn-primary"
disabled={!nomeNovoPerfil.trim() || processando}
onclick={perfilSendoEditado ? editarPerfil : criarNovoPerfil}
disabled={!podeSalvarNovoPerfil}
onclick={criarNovoPerfil}
>
{#if processando}
{#if criandoNovoPerfil}
<span class="loading loading-spinner"></span>
Processando...
Criando perfil...
{:else}
{perfilSendoEditado ? "Salvar Alterações" : "Criar Perfil"}
Salvar perfil
{/if}
</button>
</div>
@@ -837,11 +785,9 @@
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button
type="button"
onclick={fecharModalGerenciarPerfis}
aria-label="Fechar modal">Fechar</button
>
<button type="button" onclick={fecharModalNovoPerfil} aria-label="Fechar modal">
fechar
</button>
</form>
</dialog>
{/if}

View File

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

View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "sgse-app",

View File

@@ -27,13 +27,13 @@
},
"devDependencies": {
"eslint": "^9.39.1",
"eslint-plugin-svelte": "^3.13.0",
"globals": "^16.5.0",
"jiti": "^2.6.1",
"turbo": "^2.5.8",
"typescript-eslint": "^8.46.3",
"prettier": "^3.6.2",
"eslint-plugin-svelte": "^3.13.0",
"prettier-plugin-tailwindcss": "^0.7.1"
"prettier-plugin-tailwindcss": "^0.7.1",
"turbo": "^2.5.8",
"typescript-eslint": "^8.46.3"
},
"dependencies": {
"@tanstack/svelte-form": "^1.23.8",

View File

@@ -8,7 +8,16 @@ import { getCurrentUserFunction } from './auth';
export const CATALOGO_RECURSOS = [
{
recurso: 'funcionarios',
acoes: ['dashboard', 'ver', 'listar', 'criar', 'editar', 'excluir']
acoes: [
'dashboard',
'ver',
'listar',
'criar',
'editar',
'excluir',
'aprovar_ausencias',
'aprovar_ferias'
]
},
{
recurso: 'simbolos',

View File

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

View File

@@ -562,15 +562,12 @@ export const obterPerfil = query({
),
handler: async (ctx) => {
const usuarioAutenticado = await getCurrentUserFunction(ctx);
console.log('Usuario autenticado:', usuarioAutenticado);
if (!usuarioAutenticado) {
return null;
}
const usuarioAtual = usuarioAutenticado;
console.log('✅ Usuário encontrado:', usuarioAtual.nome);
// Buscar fotoPerfil URL se existir
let fotoPerfilUrl = null;
if (usuarioAtual.fotoPerfil) {