Fix page with lint errors #15

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

View File

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

View File

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

View File

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

View File

@@ -27,13 +27,13 @@
}, },
"devDependencies": { "devDependencies": {
"eslint": "^9.39.1", "eslint": "^9.39.1",
"eslint-plugin-svelte": "^3.13.0",
"globals": "^16.5.0", "globals": "^16.5.0",
"jiti": "^2.6.1", "jiti": "^2.6.1",
"turbo": "^2.5.8",
"typescript-eslint": "^8.46.3",
"prettier": "^3.6.2", "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": { "dependencies": {
"@tanstack/svelte-form": "^1.23.8", "@tanstack/svelte-form": "^1.23.8",

View File

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

View File

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

View File

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