Emp perfis #25

Merged
killer-cf merged 4 commits from emp-perfis into master 2025-11-15 00:58:00 +00:00
25 changed files with 1584 additions and 1608 deletions

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { useConvexClient } from "convex-svelte"; import { useConvexClient } from 'convex-svelte';
import { resolve } from '$app/paths';
import { import {
ExternalLink, ExternalLink,
FileText, FileText,
@@ -7,8 +8,8 @@
Upload, Upload,
Trash2, Trash2,
Eye, Eye,
RefreshCw, RefreshCw
} from "lucide-svelte"; } from 'lucide-svelte';
interface Props { interface Props {
label: string; label: string;
@@ -27,48 +28,47 @@
disabled = false, disabled = false,
required = false, required = false,
onUpload, onUpload,
onRemove, onRemove
}: Props = $props(); }: Props = $props();
const client = useConvexClient(); const client = useConvexClient() as unknown as {
storage: {
getUrl: (id: string) => Promise<string | null>;
};
};
let fileInput: HTMLInputElement; let fileInput: HTMLInputElement;
let uploading = $state(false); let uploading = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let fileName = $state<string>(""); let fileName = $state<string>('');
let fileType = $state<string>(""); let fileType = $state<string>('');
let previewUrl = $state<string | null>(null); let previewUrl = $state<string | null>(null);
let fileUrl = $state<string | null>(null); let fileUrl = $state<string | null>(null);
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = [ const ALLOWED_TYPES = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'];
"application/pdf",
"image/jpeg",
"image/jpg",
"image/png",
];
// Buscar URL do arquivo quando houver um storageId // Buscar URL do arquivo quando houver um storageId
$effect(() => { $effect(() => {
if (value && !fileName) { if (value && !fileName) {
// Tem storageId mas não é um upload recente // Tem storageId mas não é um upload recente
loadExistingFile(value); void loadExistingFile(value);
} }
}); });
async function loadExistingFile(storageId: string) { async function loadExistingFile(storageId: string) {
try { try {
const url = await client.storage.getUrl(storageId as any); const url = await client.storage.getUrl(storageId);
if (url) { if (url) {
async function handleFileSelect(event: Event) { fileUrl = url;
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
// Detectar tipo pelo URL ou assumir PDF // Detectar tipo pelo URL ou assumir PDF
if (url.includes('.pdf') || url.includes('application/pdf')) { if (url.includes('.pdf') || url.includes('application/pdf')) {
fileType = 'application/pdf'; fileType = 'application/pdf';
} else { } else {
fileType = 'image/jpeg'; fileType = 'image/jpeg';
previewUrl = url; // Para imagens, a URL serve como preview // Para imagens, a URL serve como preview
previewUrl = url;
} }
} }
} catch (err) { } catch (err) {
@@ -76,19 +76,27 @@
} }
} }
async function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) {
return;
}
error = null; error = null;
// Validate file size // Validate file size
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
error = "Arquivo muito grande. Tamanho máximo: 10MB"; error = 'Arquivo muito grande. Tamanho máximo: 10MB';
target.value = ""; target.value = '';
return; return;
} }
// Validate file type // Validate file type
if (!ALLOWED_TYPES.includes(file.type)) { if (!ALLOWED_TYPES.includes(file.type)) {
error = "Tipo de arquivo não permitido. Use PDF ou imagens (JPG, PNG)"; error = 'Tipo de arquivo não permitido. Use PDF ou imagens (JPG, PNG)';
target.value = ""; target.value = '';
return; return;
} }
@@ -98,38 +106,52 @@
fileType = file.type; fileType = file.type;
// Create preview for images // Create preview for images
if (file.type.startsWith("image/")) { if (file.type.startsWith('image/')) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
previewUrl = e.target?.result as string; const result = e.target?.result;
if (typeof result === 'string') {
previewUrl = result;
}
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
} else {
previewUrl = null;
} }
await onUpload(file); await onUpload(file);
} catch (err: any) { } catch (err: unknown) {
error = err?.message || "Erro ao fazer upload do arquivo"; if (err instanceof Error) {
error = err.message || 'Erro ao fazer upload do arquivo';
} else {
error = 'Erro ao fazer upload do arquivo';
}
previewUrl = null; previewUrl = null;
} finally { } finally {
uploading = false; uploading = false;
target.value = ""; target.value = '';
} }
} }
async function handleRemove() { async function handleRemove() {
if (!confirm("Tem certeza que deseja remover este arquivo?")) { if (!confirm('Tem certeza que deseja remover este arquivo?')) {
return; return;
} }
try { try {
uploading = true; uploading = true;
await onRemove(); await onRemove();
fileName = ""; fileName = '';
fileType = ""; fileType = '';
previewUrl = null; previewUrl = null;
fileUrl = null; fileUrl = null;
} catch (err: any) { error = null;
error = err?.message || "Erro ao remover arquivo"; } catch (err: unknown) {
if (err instanceof Error) {
error = err.message || 'Erro ao remover arquivo';
} else {
error = 'Erro ao remover arquivo';
}
} finally { } finally {
uploading = false; uploading = false;
} }
@@ -137,7 +159,7 @@
function handleView() { function handleView() {
if (fileUrl) { if (fileUrl) {
window.open(fileUrl, "_blank"); window.open(fileUrl, '_blank');
} }
} }
@@ -148,18 +170,15 @@
<div class="form-control w-full"> <div class="form-control w-full">
<label class="label" for="file-upload-input"> <label class="label" for="file-upload-input">
<span class="label-text font-medium flex items-center gap-2"> <span class="label-text flex items-center gap-2 font-medium">
{label} {label}
{#if required} {#if required}
<span class="text-error">*</span> <span class="text-error">*</span>
{/if} {/if}
{#if helpUrl} {#if helpUrl}
<div <div class="tooltip tooltip-right" data-tip="Clique para acessar o link">
class="tooltip tooltip-right"
data-tip="Clique para acessar o link"
>
<a <a
href={helpUrl} href={resolve(helpUrl)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="text-primary hover:text-primary-focus transition-colors" class="text-primary hover:text-primary-focus transition-colors"
@@ -194,17 +213,17 @@
</div> </div>
{:else} {:else}
<div class="bg-success/10 flex h-12 w-12 items-center justify-center rounded"> <div class="bg-success/10 flex h-12 w-12 items-center justify-center rounded">
<File class="text-success h-6 w-6" strokeWidth={2} /> <FileIcon class="text-success h-6 w-6" strokeWidth={2} />
</div> </div>
{/if} {/if}
</div> </div>
<!-- File info --> <!-- File info -->
<div class="flex-1 min-w-0"> <div class="min-w-0 flex-1">
<p class="text-sm font-medium truncate"> <p class="truncate text-sm font-medium">
{fileName || "Arquivo anexado"} {fileName || 'Arquivo anexado'}
</p> </p>
<p class="text-xs text-base-content/60"> <p class="text-base-content/60 text-xs">
{#if uploading} {#if uploading}
Carregando... Carregando...
{:else} {:else}

View File

@@ -1,16 +1,15 @@
<script lang="ts"> <script lang="ts">
import { useQuery } from "convex-svelte"; import { useQuery } from 'convex-svelte';
import { api } from "@sgse-app/backend/convex/_generated/api"; import { api } from '@sgse-app/backend/convex/_generated/api';
import { onMount } from "svelte"; import { onMount } from 'svelte';
import { page } from "$app/stores"; import type { Snippet } from 'svelte';
import type { Snippet } from "svelte";
let { let {
children, children,
requireAuth = true, requireAuth = true,
allowedRoles = [], allowedRoles = [],
maxLevel = 3, maxLevel = 3,
redirectTo = "/", redirectTo = '/'
}: { }: {
children: Snippet; children: Snippet;
requireAuth?: boolean; requireAuth?: boolean;
@@ -41,9 +40,7 @@
// Verificar roles // Verificar roles
if (allowedRoles.length > 0 && currentUser?.data) { if (allowedRoles.length > 0 && currentUser?.data) {
const hasRole = allowedRoles.includes( const hasRole = allowedRoles.includes(currentUser.data.role?.nome ?? '');
currentUser.data.role?.nome ?? "",
);
if (!hasRole) { if (!hasRole) {
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`; window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
@@ -69,10 +66,10 @@
</script> </script>
{#if isChecking} {#if isChecking}
<div class="flex justify-center items-center min-h-screen"> <div class="flex min-h-screen items-center justify-center">
<div class="text-center"> <div class="text-center">
<span class="loading loading-spinner loading-lg text-primary"></span> <span class="loading loading-spinner loading-lg text-primary"></span>
<p class="mt-4 text-base-content/70">Verificando permissões...</p> <p class="text-base-content/70 mt-4">Verificando permissões...</p>
</div> </div>
</div> </div>
{:else if hasAccess} {:else if hasAccess}

View File

@@ -7,6 +7,7 @@
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { UserPlus, Mail } from "lucide-svelte"; import { UserPlus, Mail } from "lucide-svelte";
import { useAuth } from "@mmailaender/convex-better-auth-svelte/svelte"; import { useAuth } from "@mmailaender/convex-better-auth-svelte/svelte";
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
let { data } = $props(); let { data } = $props();
@@ -128,6 +129,7 @@
} }
</script> </script>
<ProtectedRoute>
<main class="container mx-auto px-4 py-4"> <main class="container mx-auto px-4 py-4">
<!-- Alerta de Acesso Negado / Autenticação --> <!-- Alerta de Acesso Negado / Autenticação -->
{#if showAlert} {#if showAlert}
@@ -823,6 +825,7 @@
</div> </div>
{/if} {/if}
</main> </main>
</ProtectedRoute>
<style> <style>
@keyframes fadeIn { @keyframes fadeIn {

View File

@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { ShoppingCart, ShoppingBag, Plus } from "lucide-svelte"; import { ShoppingCart, ShoppingBag, Plus } from "lucide-svelte";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
</script> </script>
<ProtectedRoute>
<main class="container mx-auto px-4 py-4"> <main class="container mx-auto px-4 py-4">
<div class="text-sm breadcrumbs mb-4"> <div class="text-sm breadcrumbs mb-4">
<ul> <ul>
@@ -41,4 +43,5 @@
</div> </div>
</div> </div>
</main> </main>
</ProtectedRoute>

View File

@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { Megaphone, Edit, Plus } from "lucide-svelte"; import { Megaphone, Edit, Plus } from "lucide-svelte";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
</script> </script>
<ProtectedRoute>
<main class="container mx-auto px-4 py-4"> <main class="container mx-auto px-4 py-4">
<div class="text-sm breadcrumbs mb-4"> <div class="text-sm breadcrumbs mb-4">
<ul> <ul>
@@ -41,4 +43,5 @@
</div> </div>
</div> </div>
</main> </main>
</ProtectedRoute>

View File

@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { BarChart3, ClipboardCheck, Plus, CheckCircle2, Clock, TrendingUp } from "lucide-svelte"; import { BarChart3, ClipboardCheck, Plus, CheckCircle2, Clock, TrendingUp } from "lucide-svelte";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
</script> </script>
<ProtectedRoute>
<main class="container mx-auto px-4 py-4"> <main class="container mx-auto px-4 py-4">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<div class="text-sm breadcrumbs mb-4"> <div class="text-sm breadcrumbs mb-4">
@@ -86,4 +88,5 @@
</div> </div>
</div> </div>
</main> </main>
</ProtectedRoute>

View File

@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { DollarSign, Building2, Plus, Calculator, TrendingUp, FileText } from "lucide-svelte"; import { DollarSign, Building2, Plus, Calculator, TrendingUp, FileText } from "lucide-svelte";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
</script> </script>
<ProtectedRoute>
<main class="container mx-auto px-4 py-4"> <main class="container mx-auto px-4 py-4">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<div class="text-sm breadcrumbs mb-4"> <div class="text-sm breadcrumbs mb-4">
@@ -86,4 +88,5 @@
</div> </div>
</div> </div>
</main> </main>
</ProtectedRoute>

View File

@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { Scale, BookOpen, Plus } from "lucide-svelte"; import { Scale, BookOpen, Plus } from "lucide-svelte";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
</script> </script>
<ProtectedRoute>
<main class="container mx-auto px-4 py-4"> <main class="container mx-auto px-4 py-4">
<div class="text-sm breadcrumbs mb-4"> <div class="text-sm breadcrumbs mb-4">
<ul> <ul>
@@ -41,4 +43,5 @@
</div> </div>
</div> </div>
</main> </main>
</ProtectedRoute>

View File

@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { FileText, ClipboardCopy, Plus, Users, FileDoc } from "lucide-svelte"; import { FileText, ClipboardCopy, Plus, Users, FileDoc } from "lucide-svelte";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
</script> </script>
<ProtectedRoute>
<main class="container mx-auto px-4 py-4"> <main class="container mx-auto px-4 py-4">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<div class="text-sm breadcrumbs mb-4"> <div class="text-sm breadcrumbs mb-4">
@@ -86,4 +88,5 @@
</div> </div>
</div> </div>
</main> </main>
</ProtectedRoute>

View File

@@ -7,6 +7,7 @@
import AprovarAusencias from '$lib/components/AprovarAusencias.svelte'; import AprovarAusencias from '$lib/components/AprovarAusencias.svelte';
import CalendarioAusencias from '$lib/components/ausencias/CalendarioAusencias.svelte'; import CalendarioAusencias from '$lib/components/ausencias/CalendarioAusencias.svelte';
import { generateAvatarGallery } from '$lib/utils/avatars'; import { generateAvatarGallery } from '$lib/utils/avatars';
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { X, Calendar } from 'lucide-svelte'; import { X, Calendar } from 'lucide-svelte';
import type { FunctionReturnType } from 'convex/server'; import type { FunctionReturnType } from 'convex/server';
@@ -365,6 +366,7 @@
} }
</script> </script>
<ProtectedRoute>
<main class="min-h-screen pb-12"> <main class="min-h-screen pb-12">
<!-- BANNER HERO PREMIUM --> <!-- BANNER HERO PREMIUM -->
<div class="relative mb-8 overflow-hidden"> <div class="relative mb-8 overflow-hidden">
@@ -2264,6 +2266,7 @@
</dialog> </dialog>
{/if} {/if}
</main> </main>
</ProtectedRoute>
<!-- Modal Wizard Solicitação de Férias --> <!-- Modal Wizard Solicitação de Férias -->
{#if mostrarWizard && funcionarioIdDisponivel} {#if mostrarWizard && funcionarioIdDisponivel}

View File

@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { Trophy, Award, Plus } from "lucide-svelte"; import { Trophy, Award, Plus } from "lucide-svelte";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
</script> </script>
<ProtectedRoute>
<main class="container mx-auto px-4 py-4"> <main class="container mx-auto px-4 py-4">
<div class="text-sm breadcrumbs mb-4"> <div class="text-sm breadcrumbs mb-4">
<ul> <ul>
@@ -41,4 +43,5 @@
</div> </div>
</div> </div>
</main> </main>
</ProtectedRoute>

View File

@@ -543,7 +543,7 @@
</div> </div>
</div> </div>
</div> </div>
{:else if catalogoQuery.data} {:else if catalogoQuery.data && catalogoQuery.data.length > 0}
<div class="space-y-2"> <div class="space-y-2">
{#each catalogoQuery.data as item (item.recurso)} {#each catalogoQuery.data as item (item.recurso)}
{@const recursoExpandido = isRecursoExpandido(roleId, item.recurso)} {@const recursoExpandido = isRecursoExpandido(roleId, item.recurso)}
@@ -576,8 +576,13 @@
<!-- Lista de ações (visível quando expandido) --> <!-- Lista de ações (visível quando expandido) -->
{#if recursoExpandido} {#if recursoExpandido}
<div class="bg-base-100 border-base-300 border-t px-4 py-3"> <div class="bg-base-100 border-base-300 border-t px-4 py-3">
{#if item.acoes.length === 0}
<p class="text-base-content/60 text-sm">
Nenhuma permissão cadastrada para este recurso.
</p>
{:else}
<div class="space-y-2"> <div class="space-y-2">
{#each ['ver', 'listar', 'criar', 'editar', 'excluir'] as acao (acao)} {#each item.acoes as acao (acao)}
<label <label
class="hover:bg-base-200 flex cursor-pointer items-center gap-3 rounded p-2 transition-colors" class="hover:bg-base-200 flex cursor-pointer items-center gap-3 rounded p-2 transition-colors"
> >
@@ -589,15 +594,22 @@
onchange={(e) => onchange={(e) =>
toggleAcao(roleId, item.recurso, acao, e.currentTarget.checked)} toggleAcao(roleId, item.recurso, acao, e.currentTarget.checked)}
/> />
<span class="flex-1 font-medium capitalize">{acao}</span> <span class="flex-1 font-medium">{acao}</span>
</label> </label>
{/each} {/each}
</div> </div>
{/if}
</div> </div>
{/if} {/if}
</div> </div>
{/each} {/each}
</div> </div>
{:else}
<div class="alert alert-info mt-4">
<span class="font-semibold">
Nenhuma permissão cadastrada ainda. Use o botão Criar permissão para começar.
</span>
</div>
{/if} {/if}
</div> </div>
</div> </div>

View File

@@ -41,19 +41,15 @@
const stats = $derived.by(() => { const stats = $derived.by(() => {
if (carregando) return null; if (carregando) return null;
const porNivel = { const nivelMaximo = roles.filter((r) => r.nivel === 0).length;
0: roles.filter((r) => r.nivel === 0).length, const nivelAdministrativo = roles.filter((r) => r.nivel === 1).length;
1: roles.filter((r) => r.nivel === 1).length, const niveisLegado = roles.filter((r) => r.nivel > 1).length;
2: roles.filter((r) => r.nivel === 2).length,
3: roles.filter((r) => r.nivel >= 3).length
};
return { return {
total: roles.length, total: roles.length,
nivelMaximo: porNivel[0], nivelMaximo,
nivelAlto: porNivel[1], nivelAdministrativo,
nivelMedio: porNivel[2], niveisLegado,
nivelBaixo: porNivel[3],
comSetor: roles.filter((r) => r.setor).length comSetor: roles.filter((r) => r.setor).length
}; };
}); });
@@ -78,10 +74,11 @@
// Filtro por nível // Filtro por nível
if (filtroNivel !== '') { if (filtroNivel !== '') {
if (filtroNivel === 3) { if (filtroNivel === 0 || filtroNivel === 1) {
resultado = resultado.filter((r) => r.nivel >= 3);
} else {
resultado = resultado.filter((r) => r.nivel === filtroNivel); resultado = resultado.filter((r) => r.nivel === filtroNivel);
} else {
// Qualquer outro valor é considerado legado
resultado = resultado.filter((r) => r.nivel > 1);
} }
} }
@@ -96,22 +93,19 @@
function obterCorNivel(nivel: number): string { function obterCorNivel(nivel: number): string {
if (nivel === 0) return 'badge-error'; if (nivel === 0) return 'badge-error';
if (nivel === 1) return 'badge-warning'; if (nivel === 1) return 'badge-warning';
if (nivel === 2) return 'badge-info'; // Níveis > 1 são considerados legado
return 'badge-ghost'; return 'badge-ghost';
} }
function obterTextoNivel(nivel: number): string { function obterTextoNivel(nivel: number): string {
if (nivel === 0) return 'Máximo'; if (nivel === 0) return 'Máximo';
if (nivel === 1) return 'Alto'; if (nivel === 1) return 'Administrativo';
if (nivel === 2) return 'Médio'; return `Legado (${nivel})`;
if (nivel === 3) return 'Baixo';
return `Nível ${nivel}`;
} }
function obterCorCardNivel(nivel: number): string { function obterCorCardNivel(nivel: number): string {
if (nivel === 0) return 'border-l-4 border-error'; if (nivel === 0) return 'border-l-4 border-error';
if (nivel === 1) return 'border-l-4 border-warning'; if (nivel === 1) return 'border-l-4 border-warning';
if (nivel === 2) return 'border-l-4 border-info';
return 'border-l-4 border-base-300'; return 'border-l-4 border-base-300';
} }
@@ -144,7 +138,7 @@
); );
</script> </script>
<ProtectedRoute allowedRoles={['ti_master', 'admin', 'ti_usuario']} maxLevel={3}> <ProtectedRoute allowedRoles={['ti_master', 'admin', 'ti_usuario']} maxLevel={1}>
<div class="container mx-auto max-w-7xl px-4 py-6"> <div class="container mx-auto max-w-7xl px-4 py-6">
<!-- Header --> <!-- Header -->
<div class="mb-8 flex items-center justify-between"> <div class="mb-8 flex items-center justify-between">
@@ -176,31 +170,24 @@
<!-- Estatísticas --> <!-- Estatísticas -->
{#if stats} {#if stats}
<div class="mb-8 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-5"> <div class="mb-8 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard title="Total de Perfis" value={stats.total} Icon={Users} color="primary" /> <StatsCard title="Total de Perfis" value={stats.total} Icon={Users} color="primary" />
<StatsCard <StatsCard
title="Nível Máximo" title="Nível Máximo (0)"
value={stats.nivelMaximo} value={stats.nivelMaximo}
description="Acesso total" description="Acesso total ao sistema"
Icon={Shield} Icon={Shield}
color="error" color="error"
/> />
<StatsCard <StatsCard
title="Nível Alto" title="Nível Administrativo (1)"
value={stats.nivelAlto} value={stats.nivelAdministrativo}
description="Acesso elevado" description="Perfis administrativos com acesso total"
Icon={AlertTriangle} Icon={AlertTriangle}
color="warning" color="warning"
/> />
<StatsCard <StatsCard
title="Nível Médio" title="Perfis com Setor"
value={stats.nivelMedio}
description="Acesso padrão"
Icon={Info}
color="info"
/>
<StatsCard
title="Com Setor"
value={stats.comSetor} value={stats.comSetor}
description={stats.total > 0 description={stats.total > 0
? ((stats.comSetor / stats.total) * 100).toFixed(0) + '% do total' ? ((stats.comSetor / stats.total) * 100).toFixed(0) + '% do total'
@@ -209,6 +196,18 @@
color="secondary" color="secondary"
/> />
</div> </div>
{#if stats.niveisLegado > 0}
<div class="alert alert-warning mb-6">
<Info class="h-5 w-5" />
<div>
<h3 class="font-bold">Perfis com níveis legados</h3>
<p class="text-sm">
Existem {stats.niveisLegado} perfis com nível acima de 1. Esses perfis continuarão
sendo tratados como nível 1 (administrativo) após a migração.
</p>
</div>
</div>
{/if}
{/if} {/if}
<!-- Filtros --> <!-- Filtros -->

View File

@@ -4,7 +4,7 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
</script> </script>
<ProtectedRoute allowedRoles={['admin', 'ti']} maxLevel={1}> <ProtectedRoute allowedRoles={['ti_master', 'admin']} maxLevel={1}>
<!-- Breadcrumb --> <!-- Breadcrumb -->
<div class="breadcrumbs mb-4 text-sm"> <div class="breadcrumbs mb-4 text-sm">
<ul> <ul>

View File

@@ -257,7 +257,7 @@
} }
</script> </script>
<ProtectedRoute allowedRoles={['ti_master', 'admin', 'ti_usuario']} maxLevel={3}> <ProtectedRoute allowedRoles={['ti_master', 'admin', 'ti_usuario']} maxLevel={1}>
<div class="container mx-auto max-w-7xl px-4 py-6"> <div class="container mx-auto max-w-7xl px-4 py-6">
<!-- Mensagem de Feedback --> <!-- Mensagem de Feedback -->
{#if mensagem} {#if mensagem}

View File

@@ -278,7 +278,7 @@
} }
</script> </script>
<ProtectedRoute allowedRoles={['ti_master', 'admin', 'ti_usuario']} maxLevel={2}> <ProtectedRoute allowedRoles={['ti_master', 'admin', 'ti_usuario']} maxLevel={1}>
<main class="container mx-auto max-w-7xl px-4 py-6"> <main class="container mx-auto max-w-7xl px-4 py-6">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<div class="breadcrumbs mb-4 text-sm"> <div class="breadcrumbs mb-4 text-sm">

View File

@@ -535,7 +535,7 @@
} }
</script> </script>
<ProtectedRoute allowedRoles={['ti_master', 'admin', 'ti_usuario']} maxLevel={3}> <ProtectedRoute allowedRoles={['ti_master', 'admin', 'ti_usuario']} maxLevel={1}>
<div class="container mx-auto max-w-7xl px-4 py-6"> <div class="container mx-auto max-w-7xl px-4 py-6">
<!-- Header --> <!-- Header -->
<div class="mb-6 flex items-center justify-between"> <div class="mb-6 flex items-center justify-between">

View File

@@ -15,8 +15,8 @@ import type * as actions_smtp from "../actions/smtp.js";
import type * as actions_utils_nodeCrypto from "../actions/utils/nodeCrypto.js"; import type * as actions_utils_nodeCrypto from "../actions/utils/nodeCrypto.js";
import type * as atestadosLicencas from "../atestadosLicencas.js"; import type * as atestadosLicencas from "../atestadosLicencas.js";
import type * as ausencias from "../ausencias.js"; import type * as ausencias from "../ausencias.js";
import type * as auth from "../auth.js";
import type * as auth_utils from "../auth/utils.js"; import type * as auth_utils from "../auth/utils.js";
import type * as auth from "../auth.js";
import type * as chat from "../chat.js"; import type * as chat from "../chat.js";
import type * as configuracaoEmail from "../configuracaoEmail.js"; import type * as configuracaoEmail from "../configuracaoEmail.js";
import type * as crons from "../crons.js"; import type * as crons from "../crons.js";
@@ -28,7 +28,6 @@ import type * as ferias from "../ferias.js";
import type * as funcionarios from "../funcionarios.js"; import type * as funcionarios from "../funcionarios.js";
import type * as healthCheck from "../healthCheck.js"; import type * as healthCheck from "../healthCheck.js";
import type * as http from "../http.js"; import type * as http from "../http.js";
import type * as limparPerfisAntigos from "../limparPerfisAntigos.js";
import type * as logsAcesso from "../logsAcesso.js"; import type * as logsAcesso from "../logsAcesso.js";
import type * as logsAtividades from "../logsAtividades.js"; import type * as logsAtividades from "../logsAtividades.js";
import type * as logsLogin from "../logsLogin.js"; import type * as logsLogin from "../logsLogin.js";
@@ -54,6 +53,14 @@ import type {
FunctionReference, FunctionReference,
} from "convex/server"; } from "convex/server";
/**
* A utility for referencing Convex functions in your app's API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
declare const fullApi: ApiFromModules<{ declare const fullApi: ApiFromModules<{
"actions/email": typeof actions_email; "actions/email": typeof actions_email;
"actions/linkPreview": typeof actions_linkPreview; "actions/linkPreview": typeof actions_linkPreview;
@@ -62,8 +69,8 @@ declare const fullApi: ApiFromModules<{
"actions/utils/nodeCrypto": typeof actions_utils_nodeCrypto; "actions/utils/nodeCrypto": typeof actions_utils_nodeCrypto;
atestadosLicencas: typeof atestadosLicencas; atestadosLicencas: typeof atestadosLicencas;
ausencias: typeof ausencias; ausencias: typeof ausencias;
auth: typeof auth;
"auth/utils": typeof auth_utils; "auth/utils": typeof auth_utils;
auth: typeof auth;
chat: typeof chat; chat: typeof chat;
configuracaoEmail: typeof configuracaoEmail; configuracaoEmail: typeof configuracaoEmail;
crons: typeof crons; crons: typeof crons;
@@ -75,7 +82,6 @@ declare const fullApi: ApiFromModules<{
funcionarios: typeof funcionarios; funcionarios: typeof funcionarios;
healthCheck: typeof healthCheck; healthCheck: typeof healthCheck;
http: typeof http; http: typeof http;
limparPerfisAntigos: typeof limparPerfisAntigos;
logsAcesso: typeof logsAcesso; logsAcesso: typeof logsAcesso;
logsAtividades: typeof logsAtividades; logsAtividades: typeof logsAtividades;
logsLogin: typeof logsLogin; logsLogin: typeof logsLogin;
@@ -95,30 +101,14 @@ declare const fullApi: ApiFromModules<{
"utils/getClientIP": typeof utils_getClientIP; "utils/getClientIP": typeof utils_getClientIP;
verificarMatriculas: typeof verificarMatriculas; verificarMatriculas: typeof verificarMatriculas;
}>; }>;
declare const fullApiWithMounts: typeof fullApi;
/**
* A utility for referencing Convex functions in your app's public API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export declare const api: FilterApi< export declare const api: FilterApi<
typeof fullApi, typeof fullApiWithMounts,
FunctionReference<any, "public"> FunctionReference<any, "public">
>; >;
/**
* A utility for referencing Convex functions in your app's internal API.
*
* Usage:
* ```js
* const myFunctionReference = internal.myModule.myFunction;
* ```
*/
export declare const internal: FilterApi< export declare const internal: FilterApi<
typeof fullApi, typeof fullApiWithMounts,
FunctionReference<any, "internal"> FunctionReference<any, "internal">
>; >;

View File

@@ -10,6 +10,7 @@
import { import {
ActionBuilder, ActionBuilder,
AnyComponents,
HttpActionBuilder, HttpActionBuilder,
MutationBuilder, MutationBuilder,
QueryBuilder, QueryBuilder,
@@ -18,9 +19,15 @@ import {
GenericQueryCtx, GenericQueryCtx,
GenericDatabaseReader, GenericDatabaseReader,
GenericDatabaseWriter, GenericDatabaseWriter,
FunctionReference,
} from "convex/server"; } from "convex/server";
import type { DataModel } from "./dataModel.js"; import type { DataModel } from "./dataModel.js";
type GenericCtx =
| GenericActionCtx<DataModel>
| GenericMutationCtx<DataModel>
| GenericQueryCtx<DataModel>;
/** /**
* Define a query in this Convex app's public API. * Define a query in this Convex app's public API.
* *
@@ -85,12 +92,11 @@ export declare const internalAction: ActionBuilder<DataModel, "internal">;
/** /**
* Define an HTTP action. * Define an HTTP action.
* *
* The wrapped function will be used to respond to HTTP requests received * This function will be used to respond to HTTP requests received by a Convex
* by a Convex deployment if the requests matches the path and method where * deployment if the requests matches the path and method where this action
* this action is routed. Be sure to route your httpAction in `convex/http.js`. * is routed. Be sure to route your action in `convex/http.js`.
* *
* @param func - The function. It receives an {@link ActionCtx} as its first argument * @param func - The function. It receives an {@link ActionCtx} as its first argument.
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/ */
export declare const httpAction: HttpActionBuilder; export declare const httpAction: HttpActionBuilder;

View File

@@ -16,6 +16,7 @@ import {
internalActionGeneric, internalActionGeneric,
internalMutationGeneric, internalMutationGeneric,
internalQueryGeneric, internalQueryGeneric,
componentsGeneric,
} from "convex/server"; } from "convex/server";
/** /**
@@ -80,14 +81,10 @@ export const action = actionGeneric;
export const internalAction = internalActionGeneric; export const internalAction = internalActionGeneric;
/** /**
* Define an HTTP action. * Define a Convex HTTP action.
* *
* The wrapped function will be used to respond to HTTP requests received * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object
* by a Convex deployment if the requests matches the path and method where * as its second.
* this action is routed. Be sure to route your httpAction in `convex/http.js`. * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument
* and a Fetch API `Request` object as its second.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/ */
export const httpAction = httpActionGeneric; export const httpAction = httpActionGeneric;

View File

@@ -1,285 +0,0 @@
import { internalMutation, query } from "./_generated/server";
import { v } from "convex/values";
/**
* Listar todos os perfis (roles) do sistema
*/
export const listarTodosRoles = query({
args: {},
returns: v.array(
v.object({
_id: v.id("roles"),
nome: v.string(),
descricao: v.string(),
nivel: v.number(),
setor: v.optional(v.string()),
customizado: v.optional(v.boolean()),
editavel: v.optional(v.boolean()),
_creationTime: v.number(),
})
),
handler: async (ctx) => {
const roles = await ctx.db.query("roles").collect();
return roles.map((role) => ({
_id: role._id,
nome: role.nome,
descricao: role.descricao,
nivel: role.nivel,
setor: role.setor,
customizado: role.customizado,
editavel: role.editavel,
_creationTime: role._creationTime,
}));
},
});
/**
* Limpar perfis antigos/duplicados
*
* CRITÉRIOS:
* - Manter apenas: ti_master (nível 0), admin (nível 2), ti_usuario (nível 2)
* - Remover: admin antigo (nível 0), ti genérico (nível 1), outros duplicados
*/
export const limparPerfisAntigos = internalMutation({
args: {},
returns: v.object({
removidos: v.array(
v.object({
nome: v.string(),
descricao: v.string(),
nivel: v.number(),
motivo: v.string(),
})
),
mantidos: v.array(
v.object({
nome: v.string(),
descricao: v.string(),
nivel: v.number(),
})
),
}),
handler: async (ctx) => {
const roles = await ctx.db.query("roles").collect();
const removidos: Array<{
nome: string;
descricao: string;
nivel: number;
motivo: string;
}> = [];
const mantidos: Array<{
nome: string;
descricao: string;
nivel: number;
}> = [];
// Perfis que devem ser mantidos (apenas 1 de cada)
const perfisCorretos = new Map<string, boolean>();
perfisCorretos.set("ti_master", false);
perfisCorretos.set("admin", false);
perfisCorretos.set("ti_usuario", false);
for (const role of roles) {
let deveManter = false;
let motivo = "";
// TI_MASTER - Manter apenas o de nível 0
if (role.nome === "ti_master") {
if (role.nivel === 0 && !perfisCorretos.get("ti_master")) {
deveManter = true;
perfisCorretos.set("ti_master", true);
} else {
motivo =
role.nivel !== 0
? "TI_MASTER deve ser nível 0, este é nível " + role.nivel
: "TI_MASTER duplicado";
}
}
// ADMIN - Manter apenas o de nível 2
else if (role.nome === "admin") {
if (role.nivel === 2 && !perfisCorretos.get("admin")) {
deveManter = true;
perfisCorretos.set("admin", true);
} else {
motivo =
role.nivel !== 2
? "ADMIN deve ser nível 2, este é nível " + role.nivel
: "ADMIN duplicado";
}
}
// TI_USUARIO - Manter apenas o de nível 2
else if (role.nome === "ti_usuario") {
if (role.nivel === 2 && !perfisCorretos.get("ti_usuario")) {
deveManter = true;
perfisCorretos.set("ti_usuario", true);
} else {
motivo =
role.nivel !== 2
? "TI_USUARIO deve ser nível 2, este é nível " + role.nivel
: "TI_USUARIO duplicado";
}
}
// Perfis genéricos antigos (remover)
else if (role.nome === "ti") {
motivo =
"Perfil genérico 'ti' obsoleto - usar 'ti_master' ou 'ti_usuario'";
}
// Outros perfis específicos de setores (manter se forem nível >= 2)
else if (
role.nome === "rh" ||
role.nome === "financeiro" ||
role.nome === "controladoria" ||
role.nome === "licitacoes" ||
role.nome === "compras" ||
role.nome === "juridico" ||
role.nome === "comunicacao" ||
role.nome === "programas_esportivos" ||
role.nome === "secretaria_executiva" ||
role.nome === "gestao_pessoas" ||
role.nome === "usuario"
) {
if (role.nivel >= 2) {
deveManter = true;
} else {
motivo = `Perfil de setor com nível incorreto (${role.nivel}), deveria ser >= 2`;
}
}
// Perfis customizados (manter sempre)
else if (role.customizado) {
deveManter = true;
}
// Outros perfis desconhecidos
else {
motivo = "Perfil desconhecido ou obsoleto";
}
if (deveManter) {
mantidos.push({
nome: role.nome,
descricao: role.descricao,
nivel: role.nivel,
});
console.log(
`✅ MANTIDO: ${role.nome} (${role.descricao}) - Nível ${role.nivel}`
);
} else {
// Verificar se há usuários usando este perfil
const usuariosComRole = await ctx.db
.query("usuarios")
.withIndex("by_role", (q) => q.eq("roleId", role._id))
.collect();
if (usuariosComRole.length > 0) {
console.log(
`⚠️ AVISO: Não é possível remover "${role.nome}" porque ${usuariosComRole.length} usuário(s) ainda usa(m) este perfil`
);
mantidos.push({
nome: role.nome,
descricao: role.descricao,
nivel: role.nivel,
});
} else {
// Remover permissões associadas
const permissoes = await ctx.db
.query("rolePermissoes")
.withIndex("by_role", (q) => q.eq("roleId", role._id))
.collect();
for (const perm of permissoes) {
await ctx.db.delete(perm._id);
}
// Remover o role
await ctx.db.delete(role._id);
removidos.push({
nome: role.nome,
descricao: role.descricao,
nivel: role.nivel,
motivo: motivo || "Não especificado",
});
console.log(
`🗑️ REMOVIDO: ${role.nome} (${role.descricao}) - Nível ${role.nivel} - Motivo: ${motivo}`
);
}
}
}
return { removidos, mantidos };
},
});
/**
* Verificar se existem perfis com níveis incorretos
*/
export const verificarNiveisIncorretos = query({
args: {},
returns: v.array(
v.object({
nome: v.string(),
descricao: v.string(),
nivelAtual: v.number(),
nivelCorreto: v.number(),
problema: v.string(),
})
),
handler: async (ctx) => {
const roles = await ctx.db.query("roles").collect();
const problemas: Array<{
nome: string;
descricao: string;
nivelAtual: number;
nivelCorreto: number;
problema: string;
}> = [];
for (const role of roles) {
// TI_MASTER deve ser nível 0
if (role.nome === "ti_master" && role.nivel !== 0) {
problemas.push({
nome: role.nome,
descricao: role.descricao,
nivelAtual: role.nivel,
nivelCorreto: 0,
problema: "TI_MASTER deve ter acesso total (nível 0)",
});
}
// ADMIN deve ser nível 2
if (role.nome === "admin" && role.nivel !== 2) {
problemas.push({
nome: role.nome,
descricao: role.descricao,
nivelAtual: role.nivel,
nivelCorreto: 2,
problema: "ADMIN deve ser editável (nível 2)",
});
}
// TI_USUARIO deve ser nível 2
if (role.nome === "ti_usuario" && role.nivel !== 2) {
problemas.push({
nome: role.nome,
descricao: role.descricao,
nivelAtual: role.nivel,
nivelCorreto: 2,
problema: "TI_USUARIO deve ser editável (nível 2)",
});
}
// Perfil genérico "ti" não deveria existir
if (role.nome === "ti") {
problemas.push({
nome: role.nome,
descricao: role.descricao,
nivelAtual: role.nivel,
nivelCorreto: -1, // Indica que deve ser removido
problema: "Perfil genérico obsoleto - usar ti_master ou ti_usuario",
});
}
}
return problemas;
},
});

View File

@@ -3,27 +3,271 @@ import { v } from 'convex/values';
import type { Doc } from './_generated/dataModel'; import type { Doc } from './_generated/dataModel';
import { getCurrentUserFunction } from './auth'; import { getCurrentUserFunction } from './auth';
// Catálogo base de recursos e ações // Catálogo de permissões base para seed controlado via mutation
// Ajuste/expanda conforme os módulos disponíveis no sistema const PERMISSOES_BASE = {
export const CATALOGO_RECURSOS = [ permissoes: [
// Funcionários
{ {
nome: 'funcionarios.dashboard',
recurso: 'funcionarios', recurso: 'funcionarios',
acoes: [ acao: 'dashboard',
'dashboard', descricao: 'Acessar o painel de funcionários'
'ver',
'listar',
'criar',
'editar',
'excluir',
'aprovar_ausencias',
'aprovar_ferias'
]
}, },
{ {
nome: 'funcionarios.ver',
recurso: 'funcionarios',
acao: 'ver',
descricao: 'Visualizar detalhes de funcionários'
},
{
nome: 'funcionarios.listar',
recurso: 'funcionarios',
acao: 'listar',
descricao: 'Listar funcionários'
},
{
nome: 'funcionarios.criar',
recurso: 'funcionarios',
acao: 'criar',
descricao: 'Criar novos funcionários'
},
{
nome: 'funcionarios.editar',
recurso: 'funcionarios',
acao: 'editar',
descricao: 'Editar dados de funcionários'
},
{
nome: 'funcionarios.excluir',
recurso: 'funcionarios',
acao: 'excluir',
descricao: 'Excluir funcionários'
},
{
nome: 'funcionarios.aprovar_ausencias',
recurso: 'funcionarios',
acao: 'aprovar_ausencias',
descricao: 'Aprovar ausências de funcionários'
},
{
nome: 'funcionarios.aprovar_ferias',
recurso: 'funcionarios',
acao: 'aprovar_ferias',
descricao: 'Aprovar férias de funcionários'
},
// Símbolos
{
nome: 'simbolos.dashboard',
recurso: 'simbolos', recurso: 'simbolos',
acoes: ['dashboard', 'ver', 'listar', 'criar', 'editar', 'excluir'] acao: 'dashboard',
descricao: 'Acessar o painel de símbolos'
},
{
nome: 'simbolos.ver',
recurso: 'simbolos',
acao: 'ver',
descricao: 'Visualizar detalhes de símbolos'
},
{
nome: 'simbolos.listar',
recurso: 'simbolos',
acao: 'listar',
descricao: 'Listar símbolos'
},
{
nome: 'simbolos.criar',
recurso: 'simbolos',
acao: 'criar',
descricao: 'Criar novos símbolos'
},
{
nome: 'simbolos.editar',
recurso: 'simbolos',
acao: 'editar',
descricao: 'Editar símbolos'
},
{
nome: 'simbolos.excluir',
recurso: 'simbolos',
acao: 'excluir',
descricao: 'Excluir símbolos'
},
// TI - Usuários
{
nome: 'ti_usuarios.listar',
recurso: 'ti_usuarios',
acao: 'listar',
descricao: 'Listar usuários do sistema'
},
{
nome: 'ti_usuarios.criar',
recurso: 'ti_usuarios',
acao: 'criar',
descricao: 'Criar novos usuários de acesso'
},
{
nome: 'ti_usuarios.editar',
recurso: 'ti_usuarios',
acao: 'editar',
descricao: 'Editar usuários de acesso'
},
{
nome: 'ti_usuarios.bloquear',
recurso: 'ti_usuarios',
acao: 'bloquear',
descricao: 'Bloquear ou desbloquear usuários'
},
// TI - Perfis
{
nome: 'ti_perfis.listar',
recurso: 'ti_perfis',
acao: 'listar',
descricao: 'Listar perfis de acesso'
},
{
nome: 'ti_perfis.criar',
recurso: 'ti_perfis',
acao: 'criar',
descricao: 'Criar novos perfis de acesso'
},
{
nome: 'ti_perfis.editar',
recurso: 'ti_perfis',
acao: 'editar',
descricao: 'Editar perfis de acesso'
},
// TI - Painel de Permissões
{
nome: 'ti_painel_permissoes.gerenciar',
recurso: 'ti_painel_permissoes',
acao: 'gerenciar',
descricao: 'Gerenciar matriz de permissões por perfil'
},
// TI - Solicitações de Acesso
{
nome: 'ti_solicitacoes_acesso.ver',
recurso: 'ti_solicitacoes_acesso',
acao: 'ver',
descricao: 'Visualizar solicitações de acesso'
},
{
nome: 'ti_solicitacoes_acesso.aprovar',
recurso: 'ti_solicitacoes_acesso',
acao: 'aprovar',
descricao: 'Aprovar solicitações de acesso'
},
{
nome: 'ti_solicitacoes_acesso.reprovar',
recurso: 'ti_solicitacoes_acesso',
acao: 'reprovar',
descricao: 'Reprovar solicitações de acesso'
},
// TI - Configurações de E-mail
{
nome: 'ti_configuracoes_email.configurar',
recurso: 'ti_configuracoes_email',
acao: 'configurar',
descricao: 'Configurar parâmetros de envio de e-mail'
},
// TI - Monitoramento
{
nome: 'ti_monitoramento.ver',
recurso: 'ti_monitoramento',
acao: 'ver',
descricao: 'Acessar painel de monitoramento geral'
},
{
nome: 'ti_monitoramento_emails.ver',
recurso: 'ti_monitoramento_emails',
acao: 'ver',
descricao: 'Acessar monitoramento de envio de e-mails'
},
// TI - Notificações
{
nome: 'ti_notificacoes.configurar',
recurso: 'ti_notificacoes',
acao: 'configurar',
descricao: 'Configurar notificações do sistema'
},
// TI - Times
{
nome: 'ti_times.gerenciar',
recurso: 'ti_times',
acao: 'gerenciar',
descricao: 'Gerenciar times/equipes de TI'
},
// TI - Painel Administrativo
{
nome: 'ti_painel_administrativo.ver',
recurso: 'ti_painel_administrativo',
acao: 'ver',
descricao: 'Acessar painel administrativo de TI'
},
// Financeiro
{
nome: 'financeiro.ver',
recurso: 'financeiro',
acao: 'ver',
descricao: 'Acessar telas do módulo de financeiro'
},
// Controladoria
{
nome: 'controladoria.ver',
recurso: 'controladoria',
acao: 'ver',
descricao: 'Acessar telas do módulo de controladoria'
},
// Licitações
{
nome: 'licitacoes.ver',
recurso: 'licitacoes',
acao: 'ver',
descricao: 'Acessar telas do módulo de licitações'
},
// Compras
{
nome: 'compras.ver',
recurso: 'compras',
acao: 'ver',
descricao: 'Acessar telas do módulo de compras'
},
// Jurídico
{
nome: 'juridico.ver',
recurso: 'juridico',
acao: 'ver',
descricao: 'Acessar telas do módulo jurídico'
},
// Comunicação
{
nome: 'comunicacao.ver',
recurso: 'comunicacao',
acao: 'ver',
descricao: 'Acessar telas do módulo de comunicação'
},
// Programas Esportivos
{
nome: 'programas_esportivos.ver',
recurso: 'programas_esportivos',
acao: 'ver',
descricao: 'Acessar telas do módulo de programas esportivos'
},
// Secretaria Executiva
{
nome: 'secretaria_executiva.ver',
recurso: 'secretaria_executiva',
acao: 'ver',
descricao: 'Acessar telas do módulo de secretaria executiva'
},
// Gestão de Pessoas
{
nome: 'gestao_pessoas.ver',
recurso: 'gestao_pessoas',
acao: 'ver',
descricao: 'Acessar telas do módulo de gestão de pessoas'
} }
] as const; ]
} as const;
export const listarRecursosEAcoes = query({ export const listarRecursosEAcoes = query({
args: {}, args: {},
@@ -33,10 +277,18 @@ export const listarRecursosEAcoes = query({
acoes: v.array(v.string()) acoes: v.array(v.string())
}) })
), ),
handler: async () => { handler: async (ctx) => {
return CATALOGO_RECURSOS.map((r) => ({ const permissoes = await ctx.db.query('permissoes').collect();
recurso: r.recurso,
acoes: [...r.acoes] const recursos: Record<string, Set<string>> = {};
for (const perm of permissoes) {
const set = (recursos[perm.recurso] ||= new Set<string>());
set.add(perm.acao);
}
return Object.entries(recursos).map(([recurso, acoes]) => ({
recurso,
acoes: Array.from(acoes).sort()
})); }));
} }
}); });
@@ -56,7 +308,7 @@ export const listarPermissoesAcoesPorRole = query({
.withIndex('by_role', (q) => q.eq('roleId', args.roleId)) .withIndex('by_role', (q) => q.eq('roleId', args.roleId))
.collect(); .collect();
// Carregar documentos de permissões // Carregar documentos de permissões vinculadas a este role
const actionsByResource: Record<string, Set<string>> = {}; const actionsByResource: Record<string, Set<string>> = {};
for (const rp of rolePerms) { for (const rp of rolePerms) {
const perm = await ctx.db.get(rp.permissaoId); const perm = await ctx.db.get(rp.permissaoId);
@@ -65,13 +317,10 @@ export const listarPermissoesAcoesPorRole = query({
set.add(perm.acao); set.add(perm.acao);
} }
// Normalizar para todos os recursos do catálogo return Object.entries(actionsByResource).map(([recurso, acoes]) => ({
const result: Array<{ recurso: string; acoes: Array<string> }> = []; recurso,
for (const item of CATALOGO_RECURSOS) { acoes: Array.from(acoes).sort()
const granted = Array.from(actionsByResource[item.recurso] ?? new Set<string>()); }));
result.push({ recurso: item.recurso, acoes: granted });
}
return result;
} }
}); });
@@ -84,24 +333,12 @@ export const atualizarPermissaoAcao = mutation({
}, },
returns: v.null(), returns: v.null(),
handler: async (ctx, args) => { handler: async (ctx, args) => {
// Garantir documento de permissão (recurso+acao) // Buscar documento de permissão (recurso+acao)
let permissao = await ctx.db const permissao = await ctx.db
.query('permissoes') .query('permissoes')
.withIndex('by_recurso_e_acao', (q) => q.eq('recurso', args.recurso).eq('acao', args.acao)) .withIndex('by_recurso_e_acao', (q) => q.eq('recurso', args.recurso).eq('acao', args.acao))
.first(); .first();
if (!permissao) {
const nome = `${args.recurso}.${args.acao}`;
const descricao = `Permite ${args.acao} em ${args.recurso}`;
const id = await ctx.db.insert('permissoes', {
nome,
descricao,
recurso: args.recurso,
acao: args.acao
});
permissao = await ctx.db.get(id);
}
if (!permissao) return null; if (!permissao) return null;
// Verificar vínculo atual // Verificar vínculo atual
@@ -128,6 +365,36 @@ export const atualizarPermissaoAcao = mutation({
} }
}); });
export const seedPermissoesBase = mutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
console.log('🔐 Seed de permissões base...');
for (const perm of PERMISSOES_BASE.permissoes) {
const existente = await ctx.db
.query('permissoes')
.withIndex('by_nome', (q) => q.eq('nome', perm.nome))
.first();
if (existente) {
console.log(` Permissão já existe: ${perm.nome}`);
continue;
}
await ctx.db.insert('permissoes', {
nome: perm.nome,
descricao: perm.descricao,
recurso: perm.recurso,
acao: perm.acao
});
console.log(` ✅ Permissão criada: ${perm.nome}`);
}
return null;
}
});
export const verificarAcao = query({ export const verificarAcao = query({
args: { args: {
usuarioId: v.id('usuarios'), usuarioId: v.id('usuarios'),

View File

@@ -1,5 +1,5 @@
import { v } from 'convex/values'; import { v } from 'convex/values';
import { query, mutation } from './_generated/server'; import { internalMutation, query, mutation } from './_generated/server';
import type { Id } from './_generated/dataModel'; import type { Id } from './_generated/dataModel';
import { getCurrentUserFunction } from './auth'; import { getCurrentUserFunction } from './auth';
@@ -98,13 +98,16 @@ export const criar = mutation({
permissoesParaCopiar = permissoesOrigem.map((item) => item.permissaoId); permissoesParaCopiar = permissoesOrigem.map((item) => item.permissaoId);
} }
const nivelAjustado = Math.min(Math.max(Math.round(args.nivel), 0), 10); // Agora só existem níveis 0 e 1.
// 0 = máximo (acesso total), 1 = administrativo (também com acesso total).
// Qualquer valor informado diferente de 0 é normalizado para 1.
const nivelNormalizado = Math.round(args.nivel) <= 0 ? 0 : 1;
const setor = args.setor?.trim(); const setor = args.setor?.trim();
const roleId = await ctx.db.insert('roles', { const roleId = await ctx.db.insert('roles', {
nome: nomeNormalizado, nome: nomeNormalizado,
descricao: args.descricao.trim() || args.nome.trim(), descricao: args.descricao.trim() || args.nome.trim(),
nivel: nivelAjustado, nivel: nivelNormalizado,
setor: setor && setor.length > 0 ? setor : undefined, setor: setor && setor.length > 0 ? setor : undefined,
customizado: true, customizado: true,
criadoPor: usuarioAtual._id, criadoPor: usuarioAtual._id,
@@ -123,3 +126,26 @@ export const criar = mutation({
return { sucesso: true as const, roleId }; return { sucesso: true as const, roleId };
} }
}); });
/**
* Migração de níveis de roles para o novo modelo (apenas 0 e 1).
* - Mantém níveis 0 e 1 como estão.
* - Converte qualquer nível > 1 para 1.
*/
export const migrarNiveisRoles = internalMutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
const roles = await ctx.db.query('roles').collect();
for (const role of roles) {
if (role.nivel <= 1) continue;
await ctx.db.patch(role._id, {
nivel: 1
});
}
return null;
}
});

File diff suppressed because it is too large Load Diff