feat: Add user management UI including filters, actions, and modals for roles, employee association, and blocking.

This commit is contained in:
2025-12-05 15:42:50 -03:00
parent 69f32a342c
commit 6a99ab74f1
9 changed files with 1034 additions and 1168 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,177 @@
<script lang="ts">
import { useConvexClient, useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import type { Usuario, Funcionario } from './types';
type Props = {
open: boolean;
usuario: Usuario | null;
onclose: () => void;
onsuccess: () => void;
};
let { open = $bindable(false), usuario, onclose, onsuccess }: Props = $props();
const client = useConvexClient();
// We load all employees. In a real scenario with thousands, we might want to paginate or search on server.
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
let funcionarios = $derived(
funcionariosQuery.data
? Array.isArray(funcionariosQuery.data)
? funcionariosQuery.data
: (funcionariosQuery.data as { data: Funcionario[] }).data
: []
);
let selectedFuncionarioId = $state<Id<'funcionarios'> | ''>('');
let processing = $state(false);
let error = $state<string | null>(null);
let searchTerm = $state('');
// Reset state when modal opens
$effect(() => {
if (open && usuario) {
selectedFuncionarioId = usuario.funcionario?._id ?? '';
searchTerm = '';
error = null;
}
});
let filteredFuncionarios = $derived.by(() => {
if (!funcionarios) return [];
if (!searchTerm) return [...funcionarios].sort((a, b) => a.nome.localeCompare(b.nome));
const searchLower = searchTerm.toLowerCase();
return funcionarios
.filter(
(f) =>
f.nome.toLowerCase().includes(searchLower) ||
f.matricula?.toLowerCase().includes(searchLower) ||
f.cpf?.toLowerCase().includes(searchLower)
)
.sort((a, b) => a.nome.localeCompare(b.nome));
});
async function handleSubmit(action: 'associar' | 'desassociar') {
if (!usuario) return;
processing = true;
error = null;
try {
if (action === 'associar') {
if (!selectedFuncionarioId) return;
await client.mutation(api.usuarios.associarFuncionario, {
usuarioId: usuario._id,
funcionarioId: selectedFuncionarioId as Id<'funcionarios'>
});
} else {
await client.mutation(api.usuarios.desassociarFuncionario, {
usuarioId: usuario._id
});
}
onsuccess();
open = false;
} catch (err) {
error = err instanceof Error ? err.message : 'Erro ao atualizar associação';
} finally {
processing = false;
}
}
</script>
{#if open}
<div class="modal modal-open">
<div class="modal-box w-11/12 max-w-2xl">
<h3 class="text-lg font-bold">Associar Funcionário</h3>
{#if usuario}
<p class="py-4">
Gerenciar vínculo de funcionário para o usuário <strong>{usuario.nome}</strong>
</p>
{#if error}
<div class="alert alert-error mb-4">
<span>{error}</span>
</div>
{/if}
<div class="form-control mb-4 w-full">
<label class="label" for="search-employee">
<span class="label-text">Buscar Funcionário</span>
</label>
<input
id="search-employee"
type="text"
class="input input-bordered w-full"
placeholder="Nome, matrícula ou CPF..."
bind:value={searchTerm}
/>
</div>
<div class="form-control h-64 w-full overflow-y-auto rounded-lg border p-2">
{#if filteredFuncionarios.length === 0}
<div class="text-base-content/50 flex h-full items-center justify-center">
Nenhum funcionário encontrado
</div>
{:else}
<ul class="menu w-full p-0">
{#each filteredFuncionarios as func (func._id)}
<li>
<button
type="button"
class={selectedFuncionarioId === func._id ? 'active' : ''}
onclick={() => (selectedFuncionarioId = func._id)}
>
<div class="flex flex-col items-start gap-1">
<span class="font-medium">{func.nome}</span>
<span class="text-xs opacity-70">
{func.descricaoCargo || 'Sem cargo'} • Mat: {func.matricula || 'N/A'}
</span>
</div>
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
<div class="modal-action justify-between">
<button
type="button"
class="btn btn-error btn-outline"
onclick={() => handleSubmit('desassociar')}
disabled={processing || !usuario?.funcionario}
>
Remover Associação
</button>
<div class="flex gap-2">
<button type="button" class="btn" onclick={onclose} disabled={processing}>
Cancelar
</button>
<button
type="button"
class="btn btn-primary"
onclick={() => handleSubmit('associar')}
disabled={processing ||
!selectedFuncionarioId ||
selectedFuncionarioId === usuario?.funcionario?._id}
>
{#if processing}
<span class="loading loading-spinner loading-xs"></span>
{/if}
Associar Selecionado
</button>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button onclick={onclose}>close</button>
</form>
</div>
{/if}

View File

@@ -0,0 +1,111 @@
<script lang="ts">
import { useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Usuario } from './types';
import { useQuery } from 'convex-svelte';
type Props = {
open: boolean;
usuario: Usuario | null;
onclose: () => void;
onsuccess: () => void;
};
let { open = $bindable(false), usuario, onclose, onsuccess }: Props = $props();
const client = useConvexClient();
const currentUserQuery = useQuery(api.auth.getCurrentUser, {});
let motivo = $state('');
let processing = $state(false);
let error = $state<string | null>(null);
// Reset state when modal opens
$effect(() => {
if (open && usuario) {
motivo = usuario.motivoBloqueio || '';
error = null;
}
});
async function handleSubmit() {
if (!usuario || !motivo.trim()) return;
const currentUser = currentUserQuery.data;
if (!currentUser) {
error = 'Você precisa estar logado para realizar esta ação.';
return;
}
processing = true;
error = null;
try {
await client.mutation(api.usuarios.bloquearUsuario, {
usuarioId: usuario._id,
motivo: motivo.trim(),
bloqueadoPorId: currentUser._id
});
onsuccess();
open = false;
} catch (err) {
error = err instanceof Error ? err.message : 'Erro ao bloquear usuário';
} finally {
processing = false;
}
}
</script>
{#if open}
<div class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">Bloquear Usuário</h3>
{#if usuario}
<p class="py-4">
Informe o motivo do bloqueio para o usuário <strong>{usuario.nome}</strong>. O usuário
perderá acesso imediato ao sistema.
</p>
{#if error}
<div class="alert alert-error mb-4">
<span>{error}</span>
</div>
{/if}
<div class="form-control w-full">
<label class="label" for="block-reason">
<span class="label-text">Motivo do Bloqueio</span>
</label>
<textarea
id="block-reason"
class="textarea textarea-bordered h-24"
placeholder="Descreva o motivo..."
bind:value={motivo}
disabled={processing}
></textarea>
</div>
{/if}
<div class="modal-action">
<button type="button" class="btn" onclick={onclose} disabled={processing}>
Cancelar
</button>
<button
type="button"
class="btn btn-error"
onclick={handleSubmit}
disabled={processing || !motivo.trim()}
>
{#if processing}
<span class="loading loading-spinner loading-xs"></span>
{/if}
Bloquear Usuário
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button onclick={onclose}>close</button>
</form>
</div>
{/if}

View File

@@ -0,0 +1,118 @@
<script lang="ts">
import { useConvexClient, useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import type { Usuario } from './types';
type Props = {
open: boolean;
usuario: Usuario | null;
onclose: () => void;
onsuccess: () => void;
};
let { open = $bindable(false), usuario, onclose, onsuccess }: Props = $props();
const client = useConvexClient();
const rolesQuery = useQuery(api.roles.listar, {});
let roles = $derived(rolesQuery.data || []);
let selectedRoleId = $state<Id<'roles'> | ''>('');
let processing = $state(false);
let error = $state<string | null>(null);
// Reset state when modal opens/changes user
$effect(() => {
if (open && usuario) {
selectedRoleId = usuario.role._id;
error = null;
}
});
async function handleSubmit() {
if (!usuario || !selectedRoleId) return;
processing = true;
error = null;
try {
await client.mutation(api.usuarios.alterarRole, {
usuarioId: usuario._id,
novaRoleId: selectedRoleId as Id<'roles'>
});
onsuccess();
open = false;
} catch (err) {
error = err instanceof Error ? err.message : 'Erro ao alterar perfil';
} finally {
processing = false;
}
}
</script>
{#if open}
<div class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">Alterar Perfil de Usuário</h3>
{#if usuario}
<p class="py-4">
Selecione o novo perfil para o usuário <strong>{usuario.nome}</strong>
</p>
{#if error}
<div class="alert alert-error mb-4">
<span>{error}</span>
</div>
{/if}
<div class="form-control w-full">
<label class="label" for="role-select">
<span class="label-text">Perfil de Acesso</span>
</label>
<select
id="role-select"
class="select select-bordered w-full"
bind:value={selectedRoleId}
disabled={processing}
>
{#each roles as role (role._id)}
<option value={role._id}>
{role.nome}
{role.admin ? '(Admin)' : ''}
</option>
{/each}
</select>
<div class="label">
<span class="label-text-alt text-base-content/60">
{#if selectedRoleId}
{@const selectedRole = roles.find((r) => r._id === selectedRoleId)}
{selectedRole?.descricao}
{/if}
</span>
</div>
</div>
{/if}
<div class="modal-action">
<button type="button" class="btn" onclick={onclose} disabled={processing}>
Cancelar
</button>
<button
type="button"
class="btn btn-primary"
onclick={handleSubmit}
disabled={processing || !selectedRoleId || selectedRoleId === usuario?.role._id}
>
{#if processing}
<span class="loading loading-spinner loading-xs"></span>
{/if}
Salvar Alterações
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button onclick={onclose}>close</button>
</form>
</div>
{/if}

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import { resolve } from '$app/paths';
</script>
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="bg-primary/10 rounded-xl p-3">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</div>
<div>
<h1 class="text-base-content text-3xl font-bold">Gestão de Usuários</h1>
<p class="text-base-content/60 mt-1">Administre os usuários do sistema</p>
</div>
</div>
<div class="flex gap-3">
<a href={resolve('/ti/usuarios/criar')} class="btn btn-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Criar Usuário
</a>
</div>
</div>

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import type { Usuario } from './types';
import {
EllipsisVertical,
Pencil,
UserCheck,
UserPlus,
Lock,
LockOpen,
Trash2
} from 'lucide-svelte';
type Props = {
usuario: Usuario;
onEditRole: (u: Usuario) => void;
onAssociateEmployee: (u: Usuario) => void;
onBlockToggle: (u: Usuario) => void;
onDelete: (u: Usuario) => void;
};
let { usuario, onEditRole, onAssociateEmployee, onBlockToggle, onDelete }: Props = $props();
</script>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-sm">
<EllipsisVertical class="inline-block h-5 w-5 stroke-current" />
</div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-1 w-52 p-2 shadow">
<li>
<button class="text-left" onclick={() => onEditRole(usuario)}>
<Pencil class="h-4 w-4" />
Editar Perfil
</button>
</li>
<li>
<button class="text-left" onclick={() => onAssociateEmployee(usuario)}>
{#if usuario.funcionario}
<UserCheck class="h-4 w-4" />
Gerenciar Vínculo
{:else}
<UserPlus class="h-4 w-4" />
Associar Funcionário
{/if}
</button>
</li>
<li>
<button class="text-left" onclick={() => onBlockToggle(usuario)}>
{#if usuario.bloqueado}
<LockOpen class="text-success h-4 w-4" />
Desbloquear
{:else}
<Lock class="text-warning h-4 w-4" />
Bloquear
{/if}
</button>
</li>
<li class="bg-error/10 text-error rounded-md">
<button class="text-left" onclick={() => onDelete(usuario)}>
<Trash2 class="h-4 w-4" />
Excluir
</button>
</li>
</ul>
</div>

View File

@@ -0,0 +1,173 @@
<script lang="ts">
type Props = {
filtroNome: string;
filtroMatricula: string;
filtroSetor: string;
filtroStatus: 'todos' | 'ativo' | 'inativo' | 'bloqueado';
filtroDataCriacaoInicio: string;
filtroDataCriacaoFim: string;
filtroUltimoAcessoInicio: string;
filtroUltimoAcessoFim: string;
setoresDisponiveis: string[];
};
let {
filtroNome = $bindable(),
filtroMatricula = $bindable(),
filtroSetor = $bindable(),
filtroStatus = $bindable(),
filtroDataCriacaoInicio = $bindable(),
filtroDataCriacaoFim = $bindable(),
filtroUltimoAcessoInicio = $bindable(),
filtroUltimoAcessoFim = $bindable(),
setoresDisponiveis
}: Props = $props();
function limparFiltros() {
filtroNome = '';
filtroMatricula = '';
filtroSetor = '';
filtroStatus = 'todos';
filtroDataCriacaoInicio = '';
filtroDataCriacaoFim = '';
filtroUltimoAcessoInicio = '';
filtroUltimoAcessoFim = '';
}
</script>
<div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body">
<div class="mb-4 flex items-center justify-between">
<h2 class="card-title">Filtros de Busca</h2>
<button type="button" class="btn btn-sm btn-outline" onclick={limparFiltros}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
Limpar Filtros
</button>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<!-- Nome -->
<div class="form-control">
<label class="label" for="filtro-nome">
<span class="label-text font-medium">Nome</span>
</label>
<input
id="filtro-nome"
type="text"
bind:value={filtroNome}
placeholder="Buscar por nome..."
class="input input-bordered input-sm"
/>
</div>
<!-- Matrícula -->
<div class="form-control">
<label class="label" for="filtro-matricula">
<span class="label-text font-medium">Matrícula</span>
</label>
<input
id="filtro-matricula"
type="text"
bind:value={filtroMatricula}
placeholder="Buscar por matrícula..."
class="input input-bordered input-sm"
/>
</div>
<!-- Setor -->
<div class="form-control">
<label class="label" for="filtro-setor">
<span class="label-text font-medium">Setor</span>
</label>
<select id="filtro-setor" bind:value={filtroSetor} class="select select-bordered select-sm">
<option value="">Todos os setores</option>
{#each setoresDisponiveis as setor (setor)}
<option value={setor}>{setor}</option>
{/each}
</select>
</div>
<!-- Status -->
<div class="form-control">
<label class="label" for="filtro-status">
<span class="label-text font-medium">Status</span>
</label>
<select
id="filtro-status"
bind:value={filtroStatus}
class="select select-bordered select-sm"
>
<option value="todos">Todos</option>
<option value="ativo">Ativo</option>
<option value="inativo">Inativo</option>
<option value="bloqueado">Bloqueado</option>
</select>
</div>
<!-- Data de Criação Início -->
<div class="form-control">
<label class="label" for="filtro-data-criacao-inicio">
<span class="label-text font-medium">Data de Criação (Início)</span>
</label>
<input
id="filtro-data-criacao-inicio"
type="date"
bind:value={filtroDataCriacaoInicio}
class="input input-bordered input-sm"
/>
</div>
<!-- Data de Criação Fim -->
<div class="form-control">
<label class="label" for="filtro-data-criacao-fim">
<span class="label-text font-medium">Data de Criação (Fim)</span>
</label>
<input
id="filtro-data-criacao-fim"
type="date"
bind:value={filtroDataCriacaoFim}
class="input input-bordered input-sm"
/>
</div>
<!-- Último Acesso Início -->
<div class="form-control">
<label class="label" for="filtro-ultimo-acesso-inicio">
<span class="label-text font-medium">Último Acesso (Início)</span>
</label>
<input
id="filtro-ultimo-acesso-inicio"
type="date"
bind:value={filtroUltimoAcessoInicio}
class="input input-bordered input-sm"
/>
</div>
<!-- Último Acesso Fim -->
<div class="form-control">
<label class="label" for="filtro-ultimo-acesso-fim">
<span class="label-text font-medium">Último Acesso (Fim)</span>
</label>
<input
id="filtro-ultimo-acesso-fim"
type="date"
bind:value={filtroUltimoAcessoFim}
class="input input-bordered input-sm"
/>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,138 @@
<script lang="ts">
import UserStatusBadge from '$lib/components/ti/UserStatusBadge.svelte';
import UserActions from './UserActions.svelte';
import type { Usuario } from './types';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
type Props = {
usuarios: Usuario[];
loading: boolean;
onEditRole: (u: Usuario) => void;
onAssociateEmployee: (u: Usuario) => void;
onBlockToggle: (u: Usuario) => void;
onDelete: (u: Usuario) => void;
};
let {
usuarios = [],
loading = false,
onEditRole,
onAssociateEmployee,
onBlockToggle,
onDelete
}: Props = $props();
function formatarData(timestamp: number | undefined): string {
if (!timestamp) return 'Nunca';
try {
return format(new Date(timestamp), 'dd/MM/yyyy HH:mm', { locale: ptBR });
} catch {
return 'Data inválida';
}
}
</script>
{#if loading}
<div class="flex h-64 items-center justify-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if usuarios.length === 0}
<div class="alert alert-info shadow-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>Nenhum usuário encontrado com os filtros atuais.</span>
</div>
{:else}
<div class="overflow-x-auto">
<table class="table w-full">
<thead>
<tr class="bg-base-200/50">
<th>Usuário</th>
<th>Role</th>
<th>Funcionário Associado</th>
<th>Status</th>
<th>Criado em</th>
<th>Último Acesso</th>
<th class="text-right">Ações</th>
</tr>
</thead>
<tbody>
{#each usuarios as usuario (usuario._id)}
<tr class="hover">
<td>
<div class="flex items-center space-x-3">
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content w-10 rounded-full">
<span class="text-xs uppercase">{usuario.nome.slice(0, 2)}</span>
</div>
</div>
<div>
<div class="font-bold">{usuario.nome}</div>
<div class="text-sm opacity-50">{usuario.email}</div>
</div>
</div>
</td>
<td>
<div class="flex flex-col gap-1">
<span class="badge badge-ghost gap-1">
{usuario.role.nome}
{#if usuario.role.admin}
<span class="text-primary text-[10px] font-bold" title="Administrador"></span>
{/if}
</span>
{#if usuario.role.erro}
<span class="text-error text-xs font-bold">Perfil inválido/apagado</span>
{/if}
</div>
</td>
<td>
{#if usuario.funcionario}
<div class="flex flex-col">
<span class="font-semibold">{usuario.funcionario.nome}</span>
<span class="text-xs opacity-70">
{usuario.funcionario.descricaoCargo || 'Sem cargo'}
</span>
</div>
{:else}
<span class="text-base-content/40 text-sm italic">Não associado</span>
{/if}
</td>
<td>
<UserStatusBadge {usuario} />
</td>
<td class="text-sm">
{formatarData(usuario.criadoEm)}
</td>
<td class="text-sm">
{formatarData(usuario.ultimoAcesso)}
</td>
<td class="text-right">
<UserActions
{usuario}
{onEditRole}
{onAssociateEmployee}
{onBlockToggle}
{onDelete}
/>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<div class="text-base-content/60 py-4 text-center text-sm">
Exibindo {usuarios.length} usuário(s)
</div>
{/if}

View File

@@ -0,0 +1,50 @@
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
export type AvisoUsuario = {
tipo: 'erro' | 'aviso' | 'info';
mensagem: string;
};
export type RoleUsuario = {
_id: Id<'roles'>;
nome: string;
admin?: boolean;
descricao: string;
erro?: boolean;
setor?: string;
};
export type Usuario = {
_id: Id<'usuarios'>;
matricula: string;
nome: string;
email: string;
ativo: boolean;
bloqueado?: boolean;
motivoBloqueio?: string;
primeiroAcesso: boolean;
ultimoAcesso?: number;
criadoEm: number;
role: RoleUsuario;
funcionario?: {
_id: Id<'funcionarios'>;
nome: string;
matricula?: string;
descricaoCargo?: string;
simboloTipo: 'cargo_comissionado' | 'funcao_gratificada';
};
avisos?: AvisoUsuario[];
};
export type Funcionario = {
_id: Id<'funcionarios'>;
nome: string;
matricula?: string;
cpf?: string;
descricaoCargo?: string;
};
export type Mensagem = {
tipo: 'success' | 'error' | 'info';
texto: string;
};