feat: Add user management UI including filters, actions, and modals for roles, employee association, and blocking.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user