Compare commits

...

5 Commits

Author SHA1 Message Date
8167a407e7 refactor: update StatsCard component and improve page layouts
- Changed the icon prop type in StatsCard from optional string to any for better flexibility with lucide-svelte components.
- Enhanced the layout of various pages by improving indentation and formatting for better readability.
- Updated the usage of lucide-svelte icons across multiple components, ensuring a consistent and modern UI.
- Refactored the handling of derived states and queries for improved performance and clarity in the codebase.
2025-11-04 15:28:13 -03:00
Kilder Costa
9ff61b325f Merge pull request #6 from killer-cf/fix-usuarios-page
Fix usuarios page
2025-11-04 14:42:21 -03:00
fbec5c46c2 feat: enhance user management with matricula retrieval and validation
- Updated user-related queries and mutations to retrieve the matricula from associated funcionario records, improving data accuracy.
- Refactored user creation and listing functionalities to ensure matricula is correctly handled and displayed.
- Enhanced error handling and validation for user operations, ensuring a more robust user management experience.
- Improved the overall structure of user-related code for better maintainability and clarity.
2025-11-04 14:37:28 -03:00
d0692c3608 chore: update editorconfig and tool versions
- Changed the indent size in .editorconfig from 4 to 2 spaces for consistency.
- Updated Node.js version in .tool-versions from 25.0.0 to 22.21.1 to align with project requirements.
2025-11-04 13:41:12 -03:00
Kilder Costa
bf67faa470 Merge pull request #5 from killer-cf/feat-ajuste-acesso
Feat ajuste acesso
2025-10-30 14:02:19 -03:00
18 changed files with 2259 additions and 1224 deletions

View File

@@ -5,7 +5,7 @@ root = true
[*]
indent_style = space
indent_size = 4
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false

View File

@@ -1 +1 @@
nodejs 25.0.0
nodejs 22.21.1

View File

@@ -2,25 +2,38 @@
interface Props {
title: string;
value: string | number;
icon?: string;
icon?: any; // Componente do lucide-svelte
trend?: {
value: number;
isPositive: boolean;
};
description?: string;
color?: "primary" | "secondary" | "accent" | "success" | "warning" | "error";
color?:
| "primary"
| "secondary"
| "accent"
| "success"
| "warning"
| "error"
| "info";
}
let { title, value, icon, trend, description, color = "primary" }: Props = $props();
let {
title,
value,
icon: Icon,
trend,
description,
color = "primary",
}: Props = $props();
</script>
<div class="stats shadow bg-base-100">
<div class="stat">
<div class="stat-figure text-{color}">
{#if icon}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-8 h-8 stroke-current">
{@html icon}
</svg>
{#if Icon}
{@const IconComponent = Icon}
<IconComponent class="inline-block w-8 h-8 stroke-current" />
{/if}
</div>
<div class="stat-title">{title}</div>
@@ -30,10 +43,9 @@
{/if}
{#if trend}
<div class="stat-desc {trend.isPositive ? 'text-success' : 'text-error'}">
{trend.isPositive ? '↗︎' : '↘︎'} {Math.abs(trend.value)}%
{trend.isPositive ? "↗︎" : "↘︎"}
{Math.abs(trend.value)}%
</div>
{/if}
</div>
</div>

View File

@@ -6,17 +6,21 @@
let limite = $state(50);
// Queries com $derived para garantir reatividade
const atividades = $derived(useQuery(api.logsAtividades.listarAtividades, { limite }));
const logins = $derived(useQuery(api.logsLogin.listarTodosLogins, { limite }));
const atividades = $derived(
useQuery(api.logsAtividades.listarAtividades, { limite }),
);
const logins = $derived(
useQuery(api.logsLogin.listarTodosLogins, { limite }),
);
function formatarData(timestamp: number) {
return new Date(timestamp).toLocaleString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
return new Date(timestamp).toLocaleString("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
@@ -27,7 +31,7 @@
excluir: "badge-error",
bloquear: "badge-error",
desbloquear: "badge-success",
resetar_senha: "badge-info"
resetar_senha: "badge-info",
};
return colors[acao] || "badge-neutral";
}
@@ -39,7 +43,7 @@
excluir: "Excluir",
bloquear: "Bloquear",
desbloquear: "Desbloquear",
resetar_senha: "Resetar Senha"
resetar_senha: "Resetar Senha",
};
return labels[acao] || acao;
}
@@ -47,8 +51,12 @@
// Estatísticas
const totalAtividades = $derived(atividades?.data?.length || 0);
const totalLogins = $derived(logins?.data?.length || 0);
const loginsSucesso = $derived(logins?.data?.filter(l => l.sucesso).length || 0);
const loginsFalha = $derived(logins?.data?.filter(l => !l.sucesso).length || 0);
const loginsSucesso = $derived(
logins?.data?.filter((l) => l.sucesso).length || 0,
);
const loginsFalha = $derived(
logins?.data?.filter((l) => !l.sucesso).length || 0,
);
</script>
<main class="container mx-auto px-4 py-6 max-w-7xl">
@@ -64,13 +72,26 @@
<div class="mb-6">
<div class="flex items-center gap-4 mb-2">
<div class="p-3 bg-blue-500/20 rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-blue-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<div>
<h1 class="text-3xl font-bold text-primary">Auditoria e Logs</h1>
<p class="text-base-content/70">Monitoramento completo de atividades e acessos do sistema</p>
<p class="text-base-content/70">
Monitoramento completo de atividades e acessos do sistema
</p>
</div>
</div>
</div>
@@ -79,8 +100,19 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="stat bg-base-100 shadow-lg rounded-lg">
<div class="stat-figure text-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
</div>
<div class="stat-title text-xs">Atividades</div>
@@ -90,8 +122,19 @@
<div class="stat bg-base-100 shadow-lg rounded-lg">
<div class="stat-figure text-success">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
/>
</svg>
</div>
<div class="stat-title text-xs">Logins Totais</div>
@@ -101,44 +144,98 @@
<div class="stat bg-base-100 shadow-lg rounded-lg">
<div class="stat-figure text-success">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div class="stat-title text-xs">Logins Bem-sucedidos</div>
<div class="stat-value text-2xl text-success">{loginsSucesso}</div>
<div class="stat-desc">{totalLogins > 0 ? Math.round((loginsSucesso / totalLogins) * 100) : 0}% de sucesso</div>
<div class="stat-desc">
{totalLogins > 0 ? Math.round((loginsSucesso / totalLogins) * 100) : 0}%
de sucesso
</div>
</div>
<div class="stat bg-base-100 shadow-lg rounded-lg">
<div class="stat-figure text-error">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div class="stat-title text-xs">Logins Falhados</div>
<div class="stat-value text-2xl text-error">{loginsFalha}</div>
<div class="stat-desc">{totalLogins > 0 ? Math.round((loginsFalha / totalLogins) * 100) : 0}% de falhas</div>
<div class="stat-desc">
{totalLogins > 0 ? Math.round((loginsFalha / totalLogins) * 100) : 0}%
de falhas
</div>
</div>
</div>
<!-- Tabs -->
<div class="tabs tabs-boxed mb-6 bg-base-100 shadow-lg p-1">
<button
class="tab flex items-center gap-2 {abaAtiva === 'atividades' ? 'tab-active' : ''}"
onclick={() => abaAtiva = "atividades"}
class="tab flex items-center gap-2 {abaAtiva === 'atividades'
? 'tab-active'
: ''}"
onclick={() => (abaAtiva = "atividades")}
>
<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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
<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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
<span class="font-medium">Atividades no Sistema</span>
</button>
<button
class="tab flex items-center gap-2 {abaAtiva === 'logins' ? 'tab-active' : ''}"
onclick={() => abaAtiva = "logins"}
class="tab flex items-center gap-2 {abaAtiva === 'logins'
? 'tab-active'
: ''}"
onclick={() => (abaAtiva = "logins")}
>
<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="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
<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="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
/>
</svg>
<span class="font-medium">Histórico de Logins</span>
</button>
@@ -149,10 +246,13 @@
<div class="card-body py-4">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="form-control">
<label class="label py-1">
<div class="label py-1">
<span class="label-text font-medium">Quantidade de registros</span>
</label>
<select bind:value={limite} class="select select-bordered select-sm w-full max-w-xs">
</div>
<select
bind:value={limite}
class="select select-bordered select-sm w-full max-w-xs"
>
<option value={20}>20 registros</option>
<option value={50}>50 registros</option>
<option value={100}>100 registros</option>
@@ -162,14 +262,36 @@
<div class="flex gap-2">
<button class="btn btn-outline btn-primary btn-sm gap-2">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Exportar CSV
</button>
<button class="btn btn-outline btn-secondary btn-sm gap-2">
<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="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
<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="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
Filtros Avançados
</button>
@@ -184,25 +306,54 @@
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h2 class="card-title text-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
Atividades Recentes
</h2>
{#if atividades?.data}
<div class="badge badge-outline badge-lg">{atividades.data.length} registro{atividades.data.length !== 1 ? 's' : ''}</div>
<div class="badge badge-outline badge-lg">
{atividades.data.length} registro{atividades.data.length !== 1
? "s"
: ""}
</div>
{/if}
</div>
{#if !atividades?.data}
<div class="flex flex-col items-center justify-center py-16">
<span class="loading loading-spinner loading-lg text-primary mb-4"></span>
<span class="loading loading-spinner loading-lg text-primary mb-4"
></span>
<p class="text-base-content/60">Carregando atividades...</p>
</div>
{:else if atividades.data.length === 0}
<div class="flex flex-col items-center justify-center py-16 text-base-content/60">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
<div
class="flex flex-col items-center justify-center py-16 text-base-content/60"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 mb-4 opacity-50"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<p class="text-lg font-medium">Nenhuma atividade registrada</p>
<p class="text-sm mt-1">As atividades do sistema aparecerão aqui</p>
@@ -224,32 +375,58 @@
<tr class="hover transition-colors">
<td>
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-base-content/40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="font-mono text-xs">{formatarData(atividade.timestamp)}</span>
<span class="font-mono text-xs"
>{formatarData(atividade.timestamp)}</span
>
</div>
</td>
<td>
<div class="flex flex-col">
<div class="font-semibold text-sm">{atividade.usuarioNome || "Sistema"}</div>
<div class="font-semibold text-sm">
{atividade.usuarioNome || "Sistema"}
</div>
{#if atividade.usuarioMatricula}
<div class="text-xs text-base-content/50 font-mono">{atividade.usuarioMatricula}</div>
<div class="text-xs text-base-content/50 font-mono">
{atividade.usuarioMatricula}
</div>
{/if}
</div>
</td>
<td>
<span class="badge {getAcaoColor(atividade.acao)} badge-sm gap-1">
<span
class="badge {getAcaoColor(
atividade.acao,
)} badge-sm gap-1"
>
{getAcaoLabel(atividade.acao)}
</span>
</td>
<td>
<span class="font-medium text-sm">{atividade.recurso}</span>
<span class="font-medium text-sm"
>{atividade.recurso}</span
>
</td>
<td>
<div class="max-w-md">
{#if atividade.detalhes}
<div class="text-xs text-base-content/70 truncate" title={atividade.detalhes}>
<div
class="text-xs text-base-content/70 truncate"
title={atividade.detalhes}
>
{atividade.detalhes}
</div>
{:else}
@@ -270,25 +447,52 @@
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h2 class="card-title text-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
/>
</svg>
Histórico de Logins
</h2>
{#if logins?.data}
<div class="badge badge-outline badge-lg">{logins.data.length} registro{logins.data.length !== 1 ? 's' : ''}</div>
<div class="badge badge-outline badge-lg">
{logins.data.length} registro{logins.data.length !== 1 ? "s" : ""}
</div>
{/if}
</div>
{#if !logins?.data}
<div class="flex flex-col items-center justify-center py-16">
<span class="loading loading-spinner loading-lg text-primary mb-4"></span>
<span class="loading loading-spinner loading-lg text-primary mb-4"
></span>
<p class="text-base-content/60">Carregando logins...</p>
</div>
{:else if logins.data.length === 0}
<div class="flex flex-col items-center justify-center py-16 text-base-content/60">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
<div
class="flex flex-col items-center justify-center py-16 text-base-content/60"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 mb-4 opacity-50"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"
/>
</svg>
<p class="text-lg font-medium">Nenhum login registrado</p>
<p class="text-sm mt-1">Os acessos ao sistema aparecerão aqui</p>
@@ -312,53 +516,112 @@
<tr class="hover transition-colors">
<td>
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-base-content/40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="font-mono text-xs">{formatarData(login.timestamp)}</span>
<span class="font-mono text-xs"
>{formatarData(login.timestamp)}</span
>
</div>
</td>
<td>
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-base-content/40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<span class="text-sm font-medium">{login.matriculaOuEmail}</span>
<span class="text-sm font-medium"
>{login.matriculaOuEmail}</span
>
</div>
</td>
<td>
<div class="flex flex-col gap-1">
{#if login.sucesso}
<span class="badge badge-success badge-sm gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
Sucesso
</span>
{:else}
<span class="badge badge-error badge-sm gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" 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
xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3"
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>
Falhou
</span>
{#if login.motivoFalha}
<div class="text-xs text-error mt-1 font-medium">{login.motivoFalha}</div>
<div class="text-xs text-error mt-1 font-medium">
{login.motivoFalha}
</div>
{/if}
{/if}
</div>
</td>
<td>
<span class="font-mono text-xs bg-base-200 px-2 py-1 rounded">{login.ipAddress || "-"}</span>
<span
class="font-mono text-xs bg-base-200 px-2 py-1 rounded"
>{login.ipAddress || "-"}</span
>
</td>
<td>
<div class="text-xs text-base-content/70">{login.device || "-"}</div>
<div class="text-xs text-base-content/70">
{login.device || "-"}
</div>
</td>
<td>
<div class="text-xs text-base-content/70">{login.browser || "-"}</div>
<div class="text-xs text-base-content/70">
{login.browser || "-"}
</div>
</td>
<td>
<div class="text-xs text-base-content/70">{login.sistema || "-"}</div>
<div class="text-xs text-base-content/70">
{login.sistema || "-"}
</div>
</td>
</tr>
{/each}
@@ -372,13 +635,25 @@
<!-- Informação -->
<div class="alert alert-info shadow-lg mt-6">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<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
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-current shrink-0 w-6 h-6"
>
<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>
<div>
<h3 class="font-bold">Informação Importante</h3>
<div class="text-sm">Os logs são armazenados permanentemente e não podem ser alterados ou excluídos.</div>
<div class="text-sm">
Os logs são armazenados permanentemente e não podem ser alterados ou
excluídos.
</div>
</div>
</div>
</main>

View File

@@ -6,19 +6,45 @@
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-4">
<div class="p-3 bg-gradient-to-br from-primary/20 to-primary/10 rounded-2xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
<div
class="p-3 bg-linear-to-br from-primary/20 to-primary/10 rounded-2xl"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10 text-primary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
</div>
<div>
<h1 class="text-4xl font-bold text-primary">Monitoramento SGSE</h1>
<p class="text-base-content/60 mt-2 text-lg">Sistema de monitoramento técnico em tempo real</p>
<p class="text-base-content/60 mt-2 text-lg">
Sistema de monitoramento técnico em tempo real
</p>
</div>
</div>
<a href="/ti" class="btn btn-ghost">
<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="M10 19l-7-7m0 0l7-7m-7 7h18" />
<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="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
Voltar
</a>
@@ -27,4 +53,3 @@
<!-- Card de Monitoramento -->
<SystemMonitorCardLocal />
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,17 @@
import { useQuery, useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import StatsCard from "$lib/components/ti/StatsCard.svelte";
import {
Users,
CheckCircle,
Ban,
Clock,
BarChart3,
Plus,
FolderTree,
FileText,
Info,
} from "lucide-svelte";
const client = useConvexClient();
const usuariosQuery = useQuery(api.usuarios.listar, {});
@@ -23,19 +34,19 @@
total: 0,
ativos: 0,
bloqueados: 0,
inativos: 0
inativos: 0,
};
}
const ativos = usuarios.filter(u => u.ativo && !u.bloqueado).length;
const bloqueados = usuarios.filter(u => u.bloqueado === true).length;
const inativos = usuarios.filter(u => !u.ativo).length;
const ativos = usuarios.filter((u) => u.ativo && !u.bloqueado).length;
const bloqueados = usuarios.filter((u) => u.bloqueado === true).length;
const inativos = usuarios.filter((u) => !u.ativo).length;
return {
total: usuarios.length,
ativos,
bloqueados,
inativos
inativos,
};
});
</script>
@@ -45,13 +56,15 @@
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-4">
<div class="p-3 bg-primary/10 rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<BarChart3 class="h-8 w-8 text-primary" />
</div>
<div>
<h1 class="text-3xl font-bold text-base-content">Dashboard Administrativo TI</h1>
<p class="text-base-content/60 mt-1">Painel de controle e monitoramento do sistema</p>
<h1 class="text-3xl font-bold text-base-content">
Dashboard Administrativo TI
</h1>
<p class="text-base-content/60 mt-1">
Painel de controle e monitoramento do sistema
</p>
</div>
</div>
</div>
@@ -59,34 +72,38 @@
<!-- Stats Cards -->
{#if stats}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- @ts-ignore - Componentes lucide-svelte são válidos em runtime -->
<StatsCard
title="Total de Usuários"
value={stats.total}
icon='<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" />'
icon={Users}
color="primary"
/>
<!-- @ts-ignore - Componentes lucide-svelte são válidos em runtime -->
<StatsCard
title="Usuários Ativos"
value={stats.ativos}
description="{stats.total > 0 ? ((stats.ativos / stats.total) * 100).toFixed(1) + '% do total' : '0% do total'}"
icon='<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />'
description={`${stats.total > 0 ? ((stats.ativos / stats.total) * 100).toFixed(1) + "% do total" : "0% do total"}`}
icon={CheckCircle}
color="success"
/>
<!-- @ts-ignore - Componentes lucide-svelte são válidos em runtime -->
<StatsCard
title="Usuários Bloqueados"
value={stats.bloqueados}
description="Requerem atenção"
icon='<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />'
icon={Ban}
color="error"
/>
<!-- @ts-ignore - Componentes lucide-svelte são válidos em runtime -->
<StatsCard
title="Usuários Inativos"
value={stats.inativos}
description="Desativados"
icon='<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />'
icon={Clock}
color="warning"
/>
</div>
@@ -102,23 +119,17 @@
<h2 class="card-title text-2xl mb-4">Ações Rápidas</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<a href="/ti/usuarios" 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>
<Plus class="h-5 w-5" />
Criar Usuário
</a>
<a href="/ti/perfis" class="btn btn-secondary">
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<FolderTree class="h-5 w-5" />
Gerenciar Perfis
</a>
<a href="/ti/auditoria" class="btn btn-accent">
<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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<FileText class="h-5 w-5" />
Ver Logs
</a>
</div>
@@ -127,9 +138,10 @@
<!-- Informação Sistema -->
<div class="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<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>Sistema de Gestão da Secretaria de Esportes - Versão 2.0 com controle avançado de acesso</span>
<Info class="stroke-current shrink-0 w-6 h-6" />
<span
>Sistema de Gestão da Secretaria de Esportes - Versão 2.0 com controle
avançado de acesso</span
>
</div>
</div>

View File

@@ -6,6 +6,21 @@
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import {
Users,
Shield,
ShieldAlert,
Info,
Building2,
Search,
X,
Eye,
Calendar,
Tag,
Settings,
User,
AlertCircle,
} from "lucide-svelte";
type Role = {
_id: Id<"roles">;
@@ -40,10 +55,10 @@
if (carregando) return null;
const porNivel = {
0: roles.filter(r => r.nivel === 0).length,
1: roles.filter(r => r.nivel === 1).length,
2: roles.filter(r => r.nivel === 2).length,
3: roles.filter(r => r.nivel >= 3).length,
0: roles.filter((r) => r.nivel === 0).length,
1: roles.filter((r) => r.nivel === 1).length,
2: roles.filter((r) => r.nivel === 2).length,
3: roles.filter((r) => r.nivel >= 3).length,
};
return {
@@ -52,7 +67,7 @@
nivelAlto: porNivel[1],
nivelMedio: porNivel[2],
nivelBaixo: porNivel[3],
comSetor: roles.filter(r => r.setor).length,
comSetor: roles.filter((r) => r.setor).length,
};
});
@@ -65,7 +80,7 @@
resultado = resultado.filter(
(r) =>
r.nome.toLowerCase().includes(buscaLower) ||
r.descricao.toLowerCase().includes(buscaLower)
r.descricao.toLowerCase().includes(buscaLower),
);
}
@@ -137,33 +152,27 @@
filtroNivel = "";
}
const temFiltrosAtivos = $derived(busca.trim() !== "" || filtroSetor !== "" || filtroNivel !== "");
const temFiltrosAtivos = $derived(
busca.trim() !== "" || filtroSetor !== "" || filtroNivel !== "",
);
</script>
<ProtectedRoute allowedRoles={["ti_master", "admin", "ti_usuario"]} maxLevel={3}>
<ProtectedRoute
allowedRoles={["ti_master", "admin", "ti_usuario"]}
maxLevel={3}
>
<div class="container mx-auto px-4 py-6 max-w-7xl">
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-4">
<div class="p-3 bg-primary/10 rounded-xl">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-primary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<Users class="h-8 w-8 text-primary" />
</div>
<div>
<h1 class="text-3xl font-bold text-base-content">Gestão de Perfis</h1>
<p class="text-base-content/60 mt-1">Visualize e gerencie os perfis de acesso do sistema</p>
<p class="text-base-content/60 mt-1">
Visualize e gerencie os perfis de acesso do sistema
</p>
</div>
</div>
</div>
@@ -171,38 +180,45 @@
<!-- Estatísticas -->
{#if stats}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
<!-- @ts-ignore - Componentes lucide-svelte são válidos em runtime -->
<StatsCard
title="Total de Perfis"
value={stats.total}
icon='<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />'
icon={Users}
color="primary"
/>
<!-- @ts-ignore - Componentes lucide-svelte são válidos em runtime -->
<StatsCard
title="Nível Máximo"
value={stats.nivelMaximo}
description="Acesso total"
icon='<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />'
icon={Shield}
color="error"
/>
<!-- @ts-ignore - Componentes lucide-svelte são válidos em runtime -->
<StatsCard
title="Nível Alto"
value={stats.nivelAlto}
description="Acesso elevado"
icon='<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />'
icon={ShieldAlert}
color="warning"
/>
<!-- @ts-ignore - Componentes lucide-svelte são válidos em runtime -->
<StatsCard
title="Nível Médio"
value={stats.nivelMedio}
description="Acesso padrão"
icon='<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" />'
icon={Info}
color="info"
/>
<!-- @ts-ignore - Componentes lucide-svelte são válidos em runtime -->
<StatsCard
title="Com Setor"
value={stats.comSetor}
description="{stats.total > 0 ? ((stats.comSetor / stats.total) * 100).toFixed(0) + '% do total' : '0%'}"
icon='<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />'
description={stats.total > 0
? ((stats.comSetor / stats.total) * 100).toFixed(0) + "% do total"
: "0%"}
icon={Building2}
color="secondary"
/>
</div>
@@ -214,38 +230,16 @@
<div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-4 mb-4">
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-primary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
<Search class="h-5 w-5 text-primary" />
<h2 class="card-title text-lg">Filtros de Busca</h2>
</div>
{#if temFiltrosAtivos}
<button type="button" class="btn btn-sm btn-outline btn-error" onclick={limparFiltros}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
<button
type="button"
class="btn btn-sm btn-outline btn-error"
onclick={limparFiltros}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<X class="h-4 w-4" />
Limpar Filtros
</button>
{/if}
@@ -265,20 +259,9 @@
placeholder="Buscar por nome ou descrição..."
class="input input-bordered w-full pl-10"
/>
<svg
xmlns="http://www.w3.org/2000/svg"
<Search
class="h-5 w-5 absolute left-3 top-1/2 transform -translate-y-1/2 text-base-content/40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
</div>
@@ -287,7 +270,11 @@
<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
id="filtro-setor"
bind:value={filtroSetor}
class="select select-bordered"
>
<option value="">Todos os setores</option>
{#each setoresDisponiveis as setor}
<option value={setor}>{setor}</option>
@@ -300,7 +287,11 @@
<label class="label" for="filtro-nivel">
<span class="label-text font-medium">Nível de Acesso</span>
</label>
<select id="filtro-nivel" bind:value={filtroNivel} class="select select-bordered">
<select
id="filtro-nivel"
bind:value={filtroNivel}
class="select select-bordered"
>
<option value="">Todos os níveis</option>
<option value={0}>Máximo (0)</option>
<option value={1}>Alto (1)</option>
@@ -312,7 +303,12 @@
<div class="mt-4 flex items-center justify-between">
<div class="text-sm text-base-content/60">
<span class="font-medium text-base-content">{rolesFiltradas.length}</span> de <span class="font-medium text-base-content">{roles.length}</span> perfil(is)
<span class="font-medium text-base-content"
>{rolesFiltradas.length}</span
>
de
<span class="font-medium text-base-content">{roles.length}</span>
perfil(is)
{#if temFiltrosAtivos}
<span class="badge badge-primary badge-sm ml-2">Filtrado</span>
{/if}
@@ -329,45 +325,28 @@
</div>
{:else if roles.length === 0}
<div class="flex flex-col items-center justify-center py-16 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 text-base-content/30"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<Users class="h-16 w-16 text-base-content/30" />
<h3 class="text-xl font-semibold mt-4">Nenhum perfil encontrado</h3>
<p class="text-base-content/60 mt-2">Não há perfis cadastrados no sistema.</p>
<p class="text-base-content/60 mt-2">
Não há perfis cadastrados no sistema.
</p>
</div>
{:else if rolesFiltradas.length === 0}
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex flex-col items-center justify-center py-16 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 text-base-content/30 mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
<div
class="flex flex-col items-center justify-center py-16 text-center"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<AlertCircle class="h-16 w-16 text-base-content/30 mb-4" />
<h3 class="text-xl font-semibold mt-4">Nenhum perfil encontrado</h3>
<p class="text-base-content/60 mt-2">Nenhum perfil corresponde aos filtros aplicados.</p>
<p class="text-base-content/60 mt-2">
Nenhum perfil corresponde aos filtros aplicados.
</p>
{#if temFiltrosAtivos}
<button class="btn btn-primary btn-sm mt-4" onclick={limparFiltros}>
<button
class="btn btn-primary btn-sm mt-4"
onclick={limparFiltros}
>
Limpar Filtros
</button>
{/if}
@@ -376,115 +355,73 @@
</div>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each rolesFiltradas as role}
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300 cursor-pointer border border-base-300 {obterCorCardNivel(role.nivel)} hover:scale-[1.02]" onclick={() => abrirDetalhes(role)}>
{#each rolesFiltradas as role (role._id)}
<div
role="button"
tabindex="0"
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300 cursor-pointer border border-base-300 {obterCorCardNivel(
role.nivel,
)} hover:scale-[1.02]"
onclick={() => abrirDetalhes(role)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
abrirDetalhes(role);
}
}}
>
<div class="card-body">
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<h2 class="card-title text-lg mb-1">{role.descricao}</h2>
<div class="badge {obterCorNivel(role.nivel)} badge-sm">{obterTextoNivel(role.nivel)}</div>
<div class="badge {obterCorNivel(role.nivel)} badge-sm">
{obterTextoNivel(role.nivel)}
</div>
</div>
<div class="p-2 bg-base-200 rounded-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-primary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<User class="h-6 w-6 text-primary" />
</div>
</div>
<div class="space-y-3 text-sm">
<div class="flex items-center gap-2 p-2 bg-base-200 rounded-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-base-content/40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
<Tag class="h-4 w-4 text-base-content/40" />
<span class="font-medium text-base-content/60"
>Nome técnico:</span
>
<code class="text-xs bg-base-100 px-2 py-1 rounded font-mono"
>{role.nome}</code
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
<span class="font-medium text-base-content/60">Nome técnico:</span>
<code class="text-xs bg-base-100 px-2 py-1 rounded font-mono">{role.nome}</code>
</div>
{#if role.setor}
<div class="flex items-center gap-2 p-2 bg-base-200 rounded-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-base-content/40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
<div
class="flex items-center gap-2 p-2 bg-base-200 rounded-lg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<Building2 class="h-4 w-4 text-base-content/40" />
<span class="font-medium text-base-content/60">Setor:</span>
<span class="font-medium">{role.setor}</span>
</div>
{/if}
<div class="flex items-center gap-2 p-2 bg-base-200 rounded-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 text-base-content/40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
/>
</svg>
<Shield class="h-4 w-4 text-base-content/40" />
<span class="font-medium text-base-content/60">Nível:</span>
<span class="font-bold text-lg">{role.nivel}</span>
</div>
</div>
<div class="card-actions justify-end mt-4 pt-4 border-t border-base-300">
<button class="btn btn-sm btn-primary btn-outline" onclick={(e) => { e.stopPropagation(); abrirDetalhes(role); }}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
<div
class="card-actions justify-end mt-4 pt-4 border-t border-base-300"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
<button
class="btn btn-sm btn-primary btn-outline"
onclick={(e) => {
e.stopPropagation();
abrirDetalhes(role);
}}
>
<Eye class="h-4 w-4 mr-1" />
Ver Detalhes
</button>
</div>
@@ -501,40 +438,41 @@
<div class="modal-box max-w-3xl">
<div class="flex items-center justify-between mb-6">
<h3 class="font-bold text-2xl">Detalhes do Perfil</h3>
<button type="button" class="btn btn-sm btn-circle btn-ghost" onclick={fecharDetalhes}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" 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>
<button
type="button"
class="btn btn-sm btn-circle btn-ghost"
onclick={fecharDetalhes}
>
<X class="h-6 w-6" />
</button>
</div>
<div class="space-y-6">
<!-- Header do Perfil -->
<div class="card bg-gradient-to-r from-primary/10 to-secondary/10 border border-primary/20">
<div
class="card bg-linear-to-r from-primary/10 to-secondary/10 border border-primary/20"
>
<div class="card-body">
<div class="flex items-start justify-between">
<div class="flex-1">
<h2 class="text-2xl font-bold mb-2">{roleSelecionada.descricao}</h2>
<h2 class="text-2xl font-bold mb-2">
{roleSelecionada.descricao}
</h2>
<div class="flex items-center gap-3">
<div class="badge {obterCorNivel(roleSelecionada.nivel)} badge-lg">{obterTextoNivel(roleSelecionada.nivel)}</div>
<span class="text-sm text-base-content/60">Nível {roleSelecionada.nivel}</span>
<div
class="badge {obterCorNivel(
roleSelecionada.nivel,
)} badge-lg"
>
{obterTextoNivel(roleSelecionada.nivel)}
</div>
<span class="text-sm text-base-content/60"
>Nível {roleSelecionada.nivel}</span
>
</div>
</div>
<div class="p-3 bg-base-100 rounded-lg shadow-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-primary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<User class="h-8 w-8 text-primary" />
</div>
</div>
</div>
@@ -545,24 +483,27 @@
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<label class="label">
<span class="label-text font-semibold flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
<span
class="label-text font-semibold flex items-center gap-2"
>
<Tag class="h-5 w-5 text-primary" />
Nome Técnico
</span>
</label>
<code class="block bg-base-200 px-4 py-3 rounded-lg text-sm font-mono mt-2">{roleSelecionada.nome}</code>
<code
class="block bg-base-200 px-4 py-3 rounded-lg text-sm font-mono mt-2"
>{roleSelecionada.nome}</code
>
</div>
</div>
<div class="card bg-base-100 border border-base-300">
<div class="card-body">
<label class="label">
<span class="label-text font-semibold flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<span
class="label-text font-semibold flex items-center gap-2"
>
<Building2 class="h-5 w-5 text-primary" />
Setor
</span>
</label>
@@ -570,7 +511,9 @@
{#if roleSelecionada.setor}
{roleSelecionada.setor}
{:else}
<span class="text-base-content/40 italic">Não especificado</span>
<span class="text-base-content/40 italic"
>Não especificado</span
>
{/if}
</p>
</div>
@@ -582,26 +525,33 @@
<div class="card-body">
<label class="label">
<span class="label-text font-semibold flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<Shield class="h-5 w-5 text-primary" />
Nível de Acesso
</span>
</label>
<div class="mt-4">
<div class="flex items-center gap-4 mb-3">
<span class="text-4xl font-bold">{roleSelecionada.nivel}</span>
<div class="badge {obterCorNivel(roleSelecionada.nivel)} badge-lg">{obterTextoNivel(roleSelecionada.nivel)}</div>
<span class="text-4xl font-bold">{roleSelecionada.nivel}</span
>
<div
class="badge {obterCorNivel(
roleSelecionada.nivel,
)} badge-lg"
>
{obterTextoNivel(roleSelecionada.nivel)}
</div>
</div>
<div class="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
<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>
<Info class="stroke-current shrink-0 w-6 h-6" />
<span class="text-sm">
{roleSelecionada.nivel === 0 && "Acesso total irrestrito ao sistema. Pode realizar todas as operações sem restrições."}
{roleSelecionada.nivel === 1 && "Acesso alto com algumas restrições. Pode realizar a maioria das operações administrativas."}
{roleSelecionada.nivel === 2 && "Acesso médio com permissões configuráveis. Pode realizar operações padrão do sistema."}
{roleSelecionada.nivel >= 3 && "Acesso limitado com permissões específicas. Operações restritas conforme configuração."}
{roleSelecionada.nivel === 0 &&
"Acesso total irrestrito ao sistema. Pode realizar todas as operações sem restrições."}
{roleSelecionada.nivel === 1 &&
"Acesso alto com algumas restrições. Pode realizar a maioria das operações administrativas."}
{roleSelecionada.nivel === 2 &&
"Acesso médio com permissões configuráveis. Pode realizar operações padrão do sistema."}
{roleSelecionada.nivel >= 3 &&
"Acesso limitado com permissões específicas. Operações restritas conforme configuração."}
</span>
</div>
</div>
@@ -613,35 +563,27 @@
<div class="card-body">
<label class="label">
<span class="label-text font-semibold flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<Calendar class="h-5 w-5 text-primary" />
Data de Criação
</span>
</label>
<p class="text-lg font-medium mt-2">{formatarData(roleSelecionada._creationTime)}</p>
<p class="text-lg font-medium mt-2">
{formatarData(roleSelecionada._creationTime)}
</p>
</div>
</div>
<!-- Informação sobre Permissões -->
<div class="alert alert-info">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-current shrink-0 w-6 h-6"
>
<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>
<Info class="stroke-current shrink-0 w-6 h-6" />
<div>
<h4 class="font-semibold mb-1">Configuração de Permissões</h4>
<p class="text-sm">
Para configurar permissões específicas deste perfil, acesse o <a href="/ti/painel-permissoes" class="link link-primary font-semibold">Painel de Permissões</a>.
Para configurar permissões específicas deste perfil, acesse o <a
href="/ti/painel-permissoes"
class="link link-primary font-semibold"
>Painel de Permissões</a
>.
</p>
</div>
</div>
@@ -652,16 +594,23 @@
Fechar
</button>
<a href="/ti/painel-permissoes" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<Settings class="h-5 w-5 mr-2" />
Configurar Permissões
</a>
</div>
</div>
<div class="modal-backdrop" onclick={fecharDetalhes}></div>
<div
class="modal-backdrop"
role="button"
tabindex="0"
onclick={fecharDetalhes}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
fecharDetalhes();
}
}}
></div>
</div>
{/if}
</ProtectedRoute>

View File

@@ -6,6 +6,22 @@
import StatsCard from "$lib/components/ti/StatsCard.svelte";
import { format } from "date-fns";
import { ptBR } from "date-fns/locale";
import {
FileText,
Clock,
CheckCircle,
XCircle,
UserPlus,
Search,
Eye,
Check,
X,
CircleCheckBig,
CircleX,
Mail,
Phone,
Calendar,
} from "lucide-svelte";
type StatusSolicitacao = "pendente" | "aprovado" | "rejeitado";
@@ -52,7 +68,9 @@
}
if ("data" in solicitacoesQuery && solicitacoesQuery.data !== undefined) {
return Array.isArray(solicitacoesQuery.data) ? solicitacoesQuery.data : [];
return Array.isArray(solicitacoesQuery.data)
? solicitacoesQuery.data
: [];
}
if (Array.isArray(solicitacoesQuery)) {
@@ -71,15 +89,21 @@
if (carregando) return null;
const total = solicitacoes.length;
const pendentes = solicitacoes.filter(s => s.status === "pendente").length;
const aprovadas = solicitacoes.filter(s => s.status === "aprovado").length;
const rejeitadas = solicitacoes.filter(s => s.status === "rejeitado").length;
const pendentes = solicitacoes.filter(
(s) => s.status === "pendente",
).length;
const aprovadas = solicitacoes.filter(
(s) => s.status === "aprovado",
).length;
const rejeitadas = solicitacoes.filter(
(s) => s.status === "rejeitado",
).length;
return {
total,
pendentes,
aprovadas,
rejeitadas
rejeitadas,
};
});
@@ -89,16 +113,17 @@
// Filtrar por status
if (filtroStatus !== "todos") {
resultado = resultado.filter(s => s.status === filtroStatus);
resultado = resultado.filter((s) => s.status === filtroStatus);
}
// Buscar por nome, matrícula ou email
if (busca.trim()) {
const termo = busca.toLowerCase().trim();
resultado = resultado.filter(s =>
resultado = resultado.filter(
(s) =>
s.nome.toLowerCase().includes(termo) ||
s.matricula.toLowerCase().includes(termo) ||
s.email.toLowerCase().includes(termo)
s.email.toLowerCase().includes(termo),
);
}
@@ -108,7 +133,9 @@
// Funções auxiliares
function formatarData(timestamp: number): string {
return format(new Date(timestamp), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR });
return format(new Date(timestamp), "dd/MM/yyyy 'às' HH:mm", {
locale: ptBR,
});
}
function formatarDataRelativa(timestamp: number): string {
@@ -118,9 +145,9 @@
const horas = Math.floor(diff / (1000 * 60 * 60));
const minutos = Math.floor(diff / (1000 * 60));
if (dias > 0) return `${dias} dia${dias > 1 ? 's' : ''} atrás`;
if (horas > 0) return `${horas} hora${horas > 1 ? 's' : ''} atrás`;
if (minutos > 0) return `${minutos} minuto${minutos > 1 ? 's' : ''} atrás`;
if (dias > 0) return `${dias} dia${dias > 1 ? "s" : ""} atrás`;
if (horas > 0) return `${horas} hora${horas > 1 ? "s" : ""} atrás`;
if (minutos > 0) return `${minutos} minuto${minutos > 1 ? "s" : ""} atrás`;
return "Agora";
}
@@ -210,7 +237,8 @@
mensagem = null;
}, 3000);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Erro ao aprovar solicitação";
const errorMessage =
error instanceof Error ? error.message : "Erro ao aprovar solicitação";
mensagem = {
tipo: "error",
texto: errorMessage,
@@ -244,7 +272,8 @@
mensagem = null;
}, 3000);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Erro ao rejeitar solicitação";
const errorMessage =
error instanceof Error ? error.message : "Erro ao rejeitar solicitação";
mensagem = {
tipo: "error",
texto: errorMessage,
@@ -255,19 +284,18 @@
}
</script>
<ProtectedRoute allowedRoles={["ti_master", "admin", "ti_usuario"]} maxLevel={3}>
<ProtectedRoute
allowedRoles={["ti_master", "admin", "ti_usuario"]}
maxLevel={3}
>
<div class="container mx-auto px-4 py-6 max-w-7xl">
<!-- Mensagem de Feedback -->
{#if mensagem}
<div class="alert alert-{mensagem.tipo} shadow-lg mb-6">
{#if mensagem.tipo === "success"}
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<CircleCheckBig class="stroke-current shrink-0 h-6 w-6" />
{:else if mensagem.tipo === "error"}
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<CircleX class="stroke-current shrink-0 h-6 w-6" />
{/if}
<span>{mensagem.texto}</span>
</div>
@@ -277,13 +305,15 @@
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-4">
<div class="p-3 bg-primary/10 rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
<UserPlus class="h-8 w-8 text-primary" />
</div>
<div>
<h1 class="text-3xl font-bold text-base-content">Solicitações de Acesso</h1>
<p class="text-base-content/60 mt-1">Gerencie e analise solicitações de acesso ao sistema</p>
<h1 class="text-3xl font-bold text-base-content">
Solicitações de Acesso
</h1>
<p class="text-base-content/60 mt-1">
Gerencie e analise solicitações de acesso ao sistema
</p>
</div>
</div>
</div>
@@ -291,34 +321,38 @@
<!-- Estatísticas -->
{#if stats}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- @ts-ignore - Componentes lucide-svelte são válidos em runtime -->
<StatsCard
title="Total de Solicitações"
value={stats.total}
icon='<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />'
icon={FileText}
color="primary"
/>
<!-- @ts-ignore - Componentes lucide-svelte são válidos em runtime -->
<StatsCard
title="Pendentes"
value={stats.pendentes}
description="{stats.total > 0 ? ((stats.pendentes / stats.total) * 100).toFixed(1) + '% do total' : '0% do total'}"
icon='<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />'
description={`${stats.total > 0 ? ((stats.pendentes / stats.total) * 100).toFixed(1) + "% do total" : "0% do total"}`}
icon={Clock}
color="warning"
/>
<!-- @ts-ignore - Componentes lucide-svelte são válidos em runtime -->
<StatsCard
title="Aprovadas"
value={stats.aprovadas}
description="{stats.total > 0 ? ((stats.aprovadas / stats.total) * 100).toFixed(1) + '% do total' : '0% do total'}"
icon='<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />'
description={`${stats.total > 0 ? ((stats.aprovadas / stats.total) * 100).toFixed(1) + "% do total" : "0% do total"}`}
icon={CheckCircle}
color="success"
/>
<!-- @ts-ignore - Componentes lucide-svelte são válidos em runtime -->
<StatsCard
title="Rejeitadas"
value={stats.rejeitadas}
description="{stats.total > 0 ? ((stats.rejeitadas / stats.total) * 100).toFixed(1) + '% do total' : '0% do total'}"
icon='<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />'
description={`${stats.total > 0 ? ((stats.rejeitadas / stats.total) * 100).toFixed(1) + "% do total" : "0% do total"}`}
icon={XCircle}
color="error"
/>
</div>
@@ -335,25 +369,25 @@
<div class="tabs tabs-boxed mb-4 bg-base-200 p-2">
<button
class="tab {filtroStatus === 'todos' ? 'tab-active' : ''}"
onclick={() => filtroStatus = "todos"}
onclick={() => (filtroStatus = "todos")}
>
Todas
</button>
<button
class="tab {filtroStatus === 'pendente' ? 'tab-active' : ''}"
onclick={() => filtroStatus = "pendente"}
onclick={() => (filtroStatus = "pendente")}
>
Pendentes
</button>
<button
class="tab {filtroStatus === 'aprovado' ? 'tab-active' : ''}"
onclick={() => filtroStatus = "aprovado"}
onclick={() => (filtroStatus = "aprovado")}
>
Aprovadas
</button>
<button
class="tab {filtroStatus === 'rejeitado' ? 'tab-active' : ''}"
onclick={() => filtroStatus = "rejeitado"}
onclick={() => (filtroStatus = "rejeitado")}
>
Rejeitadas
</button>
@@ -361,9 +395,11 @@
<!-- Campo de Busca -->
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Buscar por nome, matrícula ou e-mail</span>
</label>
<div class="label">
<span class="label-text font-semibold"
>Buscar por nome, matrícula ou e-mail</span
>
</div>
<div class="relative">
<input
type="text"
@@ -371,9 +407,9 @@
class="input input-bordered w-full pl-10"
bind:value={busca}
/>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 absolute left-3 top-1/2 transform -translate-y-1/2 text-base-content/50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<Search
class="h-5 w-5 absolute left-3 top-1/2 transform -translate-y-1/2 text-base-content/50"
/>
</div>
</div>
</div>
@@ -387,10 +423,10 @@
{:else if solicitacoesFiltradas.length === 0}
<div class="card bg-base-100 shadow-xl">
<div class="card-body text-center py-20">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto text-base-content/30 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 class="text-xl font-semibold text-base-content/70 mb-2">Nenhuma solicitação encontrada</h3>
<FileText class="h-16 w-16 mx-auto text-base-content/30 mb-4" />
<h3 class="text-xl font-semibold text-base-content/70 mb-2">
Nenhuma solicitação encontrada
</h3>
<p class="text-base-content/50">
{#if busca.trim() || filtroStatus !== "todos"}
Tente ajustar os filtros ou a busca.
@@ -403,47 +439,60 @@
{:else}
<div class="grid grid-cols-1 gap-4">
{#each solicitacoesFiltradas as solicitacao}
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow">
<div
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow"
>
<div class="card-body">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div
class="flex flex-col md:flex-row md:items-center md:justify-between gap-4"
>
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="text-xl font-bold text-base-content">{solicitacao.nome}</h3>
<span class="badge {getStatusBadge(solicitacao.status)} badge-lg">
<h3 class="text-xl font-bold text-base-content">
{solicitacao.nome}
</h3>
<span
class="badge {getStatusBadge(
solicitacao.status,
)} badge-lg"
>
{getStatusTexto(solicitacao.status)}
</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-2 text-sm text-base-content/70">
<div
class="grid grid-cols-1 md:grid-cols-3 gap-2 text-sm text-base-content/70"
>
<div class="flex items-center gap-2">
<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="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2" />
</svg>
<UserPlus class="h-4 w-4" />
<span class="font-semibold">Matrícula:</span>
<span>{solicitacao.matricula}</span>
</div>
<div class="flex items-center gap-2">
<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="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<Mail class="h-4 w-4" />
<span class="font-semibold">E-mail:</span>
<span>{solicitacao.email}</span>
</div>
<div class="flex items-center gap-2">
<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="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
<Phone class="h-4 w-4" />
<span class="font-semibold">Telefone:</span>
<span>{solicitacao.telefone}</span>
</div>
</div>
<div class="mt-3 text-xs text-base-content/50">
<span class="font-semibold">Solicitado em:</span> {formatarData(solicitacao.dataSolicitacao)} ({formatarDataRelativa(solicitacao.dataSolicitacao)})
<div
class="mt-3 text-xs text-base-content/50 flex items-center gap-2"
>
<Calendar class="h-3 w-3" />
<span class="font-semibold">Solicitado em:</span>
{formatarData(solicitacao.dataSolicitacao)} ({formatarDataRelativa(
solicitacao.dataSolicitacao,
)})
{#if solicitacao.dataResposta}
<span class="ml-4 font-semibold">Processado em:</span> {formatarData(solicitacao.dataResposta)}
<span class="ml-4 font-semibold">Processado em:</span>
{formatarData(solicitacao.dataResposta)}
{/if}
</div>
</div>
@@ -453,10 +502,7 @@
class="btn btn-sm btn-outline btn-primary"
onclick={() => abrirDetalhes(solicitacao)}
>
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<Eye class="h-4 w-4" />
Ver Detalhes
</button>
@@ -465,9 +511,7 @@
class="btn btn-sm btn-success"
onclick={() => abrirAprovar(solicitacao)}
>
<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="M5 13l4 4L19 7" />
</svg>
<Check class="h-4 w-4" />
Aprovar
</button>
@@ -475,9 +519,7 @@
class="btn btn-sm btn-error"
onclick={() => abrirRejeitar(solicitacao)}
>
<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>
<X class="h-4 w-4" />
Rejeitar
</button>
{/if}
@@ -497,44 +539,58 @@
<div class="space-y-4">
<div class="flex items-center gap-3 mb-4">
<span class="badge {getStatusBadge(solicitacaoSelecionada.status)} badge-lg">
<span
class="badge {getStatusBadge(
solicitacaoSelecionada.status,
)} badge-lg"
>
{getStatusTexto(solicitacaoSelecionada.status)}
</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="label">
<div class="label">
<span class="label-text font-semibold">Nome Completo</span>
</label>
<div class="input input-bordered">{solicitacaoSelecionada.nome}</div>
</div>
<div class="input input-bordered">
{solicitacaoSelecionada.nome}
</div>
</div>
<div>
<label class="label">
<div class="label">
<span class="label-text font-semibold">Matrícula</span>
</label>
<div class="input input-bordered">{solicitacaoSelecionada.matricula}</div>
</div>
<div class="input input-bordered">
{solicitacaoSelecionada.matricula}
</div>
</div>
<div>
<label class="label">
<div class="label">
<span class="label-text font-semibold">E-mail</span>
</label>
<div class="input input-bordered">{solicitacaoSelecionada.email}</div>
</div>
<div class="input input-bordered">
{solicitacaoSelecionada.email}
</div>
</div>
<div>
<label class="label">
<div class="label">
<span class="label-text font-semibold">Telefone</span>
</label>
<div class="input input-bordered">{solicitacaoSelecionada.telefone}</div>
</div>
<div class="input input-bordered">
{solicitacaoSelecionada.telefone}
</div>
</div>
<div>
<label class="label">
<span class="label-text font-semibold">Data da Solicitação</span>
</label>
<div class="label">
<span class="label-text font-semibold"
>Data da Solicitação</span
>
</div>
<div class="input input-bordered">
{formatarData(solicitacaoSelecionada.dataSolicitacao)}
</div>
@@ -542,9 +598,11 @@
{#if solicitacaoSelecionada.dataResposta}
<div>
<label class="label">
<span class="label-text font-semibold">Data de Processamento</span>
</label>
<div class="label">
<span class="label-text font-semibold"
>Data de Processamento</span
>
</div>
<div class="input input-bordered">
{formatarData(solicitacaoSelecionada.dataResposta)}
</div>
@@ -554,9 +612,9 @@
{#if solicitacaoSelecionada.observacoes}
<div>
<label class="label">
<div class="label">
<span class="label-text font-semibold">Observações</span>
</label>
</div>
<div class="textarea textarea-bordered min-h-24">
{solicitacaoSelecionada.observacoes}
</div>
@@ -582,17 +640,22 @@
<div class="mb-4">
<p class="text-base-content/70 mb-2">
Você está prestes a aprovar a solicitação de acesso de <strong>{solicitacaoSelecionada.nome}</strong>.
Você está prestes a aprovar a solicitação de acesso de <strong
>{solicitacaoSelecionada.nome}</strong
>.
</p>
<p class="text-sm text-base-content/60">
Após aprovar, o sistema permitirá que esta pessoa solicite acesso ao sistema.
Após aprovar, o sistema permitirá que esta pessoa solicite acesso
ao sistema.
</p>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-semibold">Observações (opcional)</span>
</label>
<div class="label">
<span class="label-text font-semibold"
>Observações (opcional)</span
>
</div>
<textarea
class="textarea textarea-bordered"
placeholder="Adicione observações sobre a aprovação..."
@@ -618,9 +681,7 @@
<span class="loading loading-spinner loading-sm"></span>
Processando...
{:else}
<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="M5 13l4 4L19 7" />
</svg>
<Check class="h-5 w-5" />
Confirmar Aprovação
{/if}
</button>
@@ -640,17 +701,22 @@
<div class="mb-4">
<p class="text-base-content/70 mb-2">
Você está prestes a rejeitar a solicitação de acesso de <strong>{solicitacaoSelecionada.nome}</strong>.
Você está prestes a rejeitar a solicitação de acesso de <strong
>{solicitacaoSelecionada.nome}</strong
>.
</p>
<p class="text-sm text-base-content/60">
Esta ação não pode ser desfeita. Recomendamos adicionar um motivo para a rejeição.
Esta ação não pode ser desfeita. Recomendamos adicionar um motivo
para a rejeição.
</p>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-semibold">Motivo da Rejeição (recomendado)</span>
</label>
<div class="label">
<span class="label-text font-semibold"
>Motivo da Rejeição (recomendado)</span
>
</div>
<textarea
class="textarea textarea-bordered"
placeholder="Descreva o motivo da rejeição..."
@@ -676,9 +742,7 @@
<span class="loading loading-spinner loading-sm"></span>
Processando...
{:else}
<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="M6 18L18 6M6 6l12 12" />
</svg>
<X class="h-5 w-5" />
Confirmar Rejeição
{/if}
</button>

View File

@@ -22,7 +22,6 @@
});
// Estados do formulário
let matricula = $state("");
let nome = $state("");
let email = $state("");
let roleId = $state("");
@@ -30,7 +29,9 @@
let senhaInicial = $state("");
let confirmarSenha = $state("");
let processando = $state(false);
let mensagem = $state<{ tipo: "success" | "error"; texto: string } | null>(null);
let mensagem = $state<{ tipo: "success" | "error"; texto: string } | null>(
null,
);
function mostrarMensagem(tipo: "success" | "error", texto: string) {
mensagem = { tipo, texto };
@@ -43,8 +44,7 @@
e.preventDefault();
// Validações
const matriculaStr = String(matricula).trim();
if (!matriculaStr || !nome.trim() || !email.trim() || !roleId || !senhaInicial) {
if (!nome.trim() || !email.trim() || !roleId || !senhaInicial) {
mostrarMensagem("error", "Preencha todos os campos obrigatórios");
return;
}
@@ -63,11 +63,12 @@
try {
const resultado = await client.mutation(api.usuarios.criar, {
matricula: matriculaStr,
nome: nome.trim(),
email: email.trim(),
roleId: roleId as Id<"roles">,
funcionarioId: funcionarioId ? (funcionarioId as Id<"funcionarios">) : undefined,
funcionarioId: funcionarioId
? (funcionarioId as Id<"funcionarios">)
: undefined,
senhaInicial: senhaInicial,
});
@@ -75,7 +76,7 @@
if (senhaGerada) {
mostrarMensagem(
"success",
`Usuário criado! SENHA TEMPORÁRIA: ${senhaGerada} - Anote esta senha, ela não será exibida novamente!`
`Usuário criado! SENHA TEMPORÁRIA: ${senhaGerada} - Anote esta senha, ela não será exibida novamente!`,
);
setTimeout(() => {
goto("/ti/usuarios");
@@ -102,17 +103,19 @@
// Auto-completar ao selecionar funcionário
$effect(() => {
if (funcionarioId && funcionarios?.data) {
const funcSelecionado = funcionarios.data.find((f: any) => f._id === funcionarioId);
const funcSelecionado = funcionarios.data.find(
(f: any) => f._id === funcionarioId,
);
if (funcSelecionado) {
email = funcSelecionado.email || email;
nome = funcSelecionado.nome || nome;
matricula = funcSelecionado.matricula || matricula;
}
}
});
function gerarSenhaAleatoria() {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$!";
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$!";
let senha = "";
for (let i = 0; i < 12; i++) {
senha += chars.charAt(Math.floor(Math.random() * chars.length));
@@ -154,8 +157,12 @@
</svg>
</div>
<div>
<h1 class="text-3xl font-bold text-base-content">Criar Novo Usuário</h1>
<p class="text-base-content/60 mt-1">Cadastre um novo usuário no sistema</p>
<h1 class="text-3xl font-bold text-base-content">
Criar Novo Usuário
</h1>
<p class="text-base-content/60 mt-1">
Cadastre um novo usuário no sistema
</p>
</div>
</div>
<a href="/ti/usuarios" class="btn btn-outline btn-primary gap-2">
@@ -248,7 +255,9 @@
<!-- Funcionário (primeiro) -->
<div class="form-control md:col-span-2">
<label class="label" for="funcionario">
<span class="label-text font-semibold">Vincular Funcionário (Opcional)</span>
<span class="label-text font-semibold"
>Vincular Funcionário (Opcional)</span
>
</label>
<select
id="funcionario"
@@ -256,34 +265,24 @@
bind:value={funcionarioId}
disabled={processando || !funcionarios?.data}
>
<option value="">Selecione um funcionário para auto-completar dados</option>
<option value=""
>Selecione um funcionário para auto-completar dados</option
>
{#if funcionarios?.data}
{#each funcionarios.data as func}
<option value={func._id}>{func.nome} - Mat: {func.matricula}</option>
<option value={func._id}
>{func.nome} - Mat: {func.matricula}</option
>
{/each}
{/if}
</select>
<div class="label">
<span class="label-text-alt">Ao selecionar, os campos serão preenchidos automaticamente</span>
<span class="label-text-alt"
>Ao selecionar, os campos serão preenchidos automaticamente</span
>
</div>
</div>
<!-- Matrícula -->
<div class="form-control">
<label class="label" for="matricula">
<span class="label-text font-semibold">Matrícula *</span>
</label>
<input
id="matricula"
type="number"
placeholder="Ex: 12345"
class="input input-bordered"
bind:value={matricula}
required
disabled={processando}
/>
</div>
<!-- Nome -->
<div class="form-control">
<label class="label" for="nome">
@@ -301,7 +300,7 @@
</div>
<!-- Email -->
<div class="form-control md:col-span-2">
<div class="form-control">
<label class="label" for="email">
<span class="label-text font-semibold">E-mail *</span>
</label>
@@ -341,7 +340,9 @@
</select>
{#if !roles?.data || !Array.isArray(roles.data)}
<div class="label">
<span class="label-text-alt text-warning">Carregando perfis disponíveis...</span>
<span class="label-text-alt text-warning"
>Carregando perfis disponíveis...</span
>
</div>
{/if}
</div>
@@ -446,7 +447,9 @@
<div class="flex-1">
<h3 class="font-bold">Senha Gerada:</h3>
<div class="flex items-center gap-2 mt-2">
<code class="bg-base-300 px-3 py-2 rounded text-lg font-mono select-all">
<code
class="bg-base-300 px-3 py-2 rounded text-lg font-mono select-all"
>
{senhaGerada}
</code>
<button
@@ -473,8 +476,8 @@
</button>
</div>
<p class="text-sm mt-2">
⚠️ <strong>IMPORTANTE:</strong> Anote esta senha! Você precisará repassá-la
manualmente ao usuário até que o SMTP seja configurado.
⚠️ <strong>IMPORTANTE:</strong> Anote esta senha! Você precisará
repassá-la manualmente ao usuário até que o SMTP seja configurado.
</p>
</div>
</div>
@@ -500,18 +503,27 @@
<h3 class="font-bold">Informações Importantes</h3>
<ul class="text-sm list-disc list-inside mt-2 space-y-1">
<li>O usuário deverá alterar a senha no primeiro acesso</li>
<li>As credenciais devem ser repassadas manualmente (por enquanto)</li>
<li>
Configure o SMTP em <a href="/ti/configuracoes-email" class="link"
>Configurações de Email</a
As credenciais devem ser repassadas manualmente (por enquanto)
</li>
<li>
Configure o SMTP em <a
href="/ti/configuracoes-email"
class="link">Configurações de Email</a
> para envio automático
</li>
</ul>
</div>
</div>
<div class="card-actions justify-end mt-8 pt-6 border-t border-base-300">
<a href="/ti/usuarios" class="btn btn-ghost gap-2" class:btn-disabled={processando}>
<div
class="card-actions justify-end mt-8 pt-6 border-t border-base-300"
>
<a
href="/ti/usuarios"
class="btn btn-ghost gap-2"
class:btn-disabled={processando}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
@@ -528,7 +540,11 @@
</svg>
Cancelar
</a>
<button type="submit" class="btn btn-primary gap-2" disabled={processando}>
<button
type="submit"
class="btn btn-primary gap-2"
disabled={processando}
>
{#if processando}
<span class="loading loading-spinner loading-sm"></span>
Criando Usuário...
@@ -556,4 +572,3 @@
</div>
</div>
</ProtectedRoute>

View File

@@ -294,12 +294,19 @@ export const login = mutation({
timestamp: agora,
});
// Obter matrícula do funcionário se houver
let matricula: string | undefined = undefined;
if (usuario.funcionarioId) {
const funcionario = await ctx.db.get(usuario.funcionarioId);
matricula = funcionario?.matricula;
}
return {
sucesso: true as const,
token,
usuario: {
_id: usuario._id,
matricula: usuario.matricula,
matricula: matricula || "",
nome: usuario.nome,
email: usuario.email,
funcionarioId: usuario.funcionarioId,
@@ -568,12 +575,19 @@ export const loginComIP = internalMutation({
timestamp: agora,
});
// Obter matrícula do funcionário se houver
let matricula: string | undefined = undefined;
if (usuario.funcionarioId) {
const funcionario = await ctx.db.get(usuario.funcionarioId);
matricula = funcionario?.matricula;
}
return {
sucesso: true as const,
token,
usuario: {
_id: usuario._id,
matricula: usuario.matricula,
matricula: matricula || "",
nome: usuario.nome,
email: usuario.email,
funcionarioId: usuario.funcionarioId,
@@ -688,11 +702,18 @@ export const verificarSessao = query({
return { valido: false as const, motivo: "Role não encontrada" };
}
// Obter matrícula do funcionário se houver
let matricula: string | undefined = undefined;
if (usuario.funcionarioId) {
const funcionario = await ctx.db.get(usuario.funcionarioId);
matricula = funcionario?.matricula;
}
return {
valido: true as const,
usuario: {
_id: usuario._id,
matricula: usuario.matricula,
matricula: matricula || "",
nome: usuario.nome,
email: usuario.email,
funcionarioId: usuario.funcionarioId,

View File

@@ -94,7 +94,9 @@ export const criarConversa = mutation({
conversaId,
remetenteId: usuarioAtual._id,
titulo: "Adicionado a grupo",
descricao: `Você foi adicionado ao grupo "${args.nome || "Sem nome"}" por ${usuarioAtual.nome}`,
descricao: `Você foi adicionado ao grupo "${
args.nome || "Sem nome"
}" por ${usuarioAtual.nome}`,
lida: false,
criadaEm: Date.now(),
});
@@ -226,7 +228,8 @@ export const enviarMensagem = mutation({
for (const participanteId of conversa.participantes) {
// ✅ MODIFICADO: Permite notificação para si mesmo se flag estiver ativa
const ehOMesmoUsuario = participanteId === usuarioAtual._id;
const deveCriarNotificacao = !ehOMesmoUsuario || args.permitirNotificacaoParaSiMesmo;
const deveCriarNotificacao =
!ehOMesmoUsuario || args.permitirNotificacaoParaSiMesmo;
if (deveCriarNotificacao) {
const tipoNotificacao = args.mencoes?.includes(participanteId)
@@ -318,7 +321,10 @@ export const cancelarMensagemAgendada = mutation({
}
if (mensagem.remetenteId !== usuarioAtual._id) {
return { sucesso: false, erro: "Você só pode cancelar suas próprias mensagens" };
return {
sucesso: false,
erro: "Você só pode cancelar suas próprias mensagens",
};
}
if (!mensagem.agendadaPara) {
@@ -611,7 +617,9 @@ export const listarConversas = query({
// Para conversas individuais, pegar o outro usuário
let outroUsuario = null;
if (conversa.tipo === "individual") {
const outroUsuarioRaw = participantes.find((p) => p?._id !== usuarioAtual._id);
const outroUsuarioRaw = participantes.find(
(p) => p?._id !== usuarioAtual._id
);
if (outroUsuarioRaw) {
// 🔄 BUSCAR DADOS ATUALIZADOS DO USUÁRIO (não usar snapshot)
const usuarioAtualizado = await ctx.db.get(outroUsuarioRaw._id);
@@ -620,7 +628,9 @@ export const listarConversas = query({
// Adicionar URL da foto de perfil
let fotoPerfilUrl = null;
if (usuarioAtualizado.fotoPerfil) {
fotoPerfilUrl = await ctx.storage.getUrl(usuarioAtualizado.fotoPerfil);
fotoPerfilUrl = await ctx.storage.getUrl(
usuarioAtualizado.fotoPerfil
);
}
outroUsuario = {
...usuarioAtualizado,
@@ -756,7 +766,16 @@ export const obterMensagensAgendadas = query({
*/
export const listarAgendamentosChat = query({
args: {},
handler: async (ctx): Promise<Array<Doc<"mensagens"> & { conversaInfo: Doc<"conversas"> | null; destinatarioInfo: Doc<"usuarios"> | null }>> => {
handler: async (
ctx
): Promise<
Array<
Doc<"mensagens"> & {
conversaInfo: Doc<"conversas"> | null;
destinatarioInfo: Doc<"usuarios"> | null;
}
>
> => {
const usuarioAtual = await getUsuarioAutenticado(ctx);
if (!usuarioAtual) {
return [];
@@ -912,20 +931,31 @@ export const listarTodosUsuarios = query({
.withIndex("by_ativo", (q) => q.eq("ativo", true))
.collect();
// Excluir o usuário atual
return usuarios
// Excluir o usuário atual e buscar matrículas
const usuariosComMatricula = await Promise.all(
usuarios
.filter((u) => u._id !== usuarioAtual._id)
.map((u) => ({
.map(async (u) => {
let matricula: string | undefined = undefined;
if (u.funcionarioId) {
const funcionario = await ctx.db.get(u.funcionarioId);
matricula = funcionario?.matricula;
}
return {
_id: u._id,
nome: u.nome,
email: u.email,
matricula: u.matricula,
matricula,
avatar: u.avatar,
fotoPerfil: u.fotoPerfil,
statusPresenca: u.statusPresenca,
statusMensagem: u.statusMensagem,
setor: u.setor,
}));
};
})
);
return usuariosComMatricula;
},
});

View File

@@ -88,9 +88,14 @@ export const listar = query({
if (log.usuarioId) {
const user = await ctx.db.get(log.usuarioId);
if (user) {
let matricula: string | undefined = undefined;
if (user.funcionarioId) {
const funcionario = await ctx.db.get(user.funcionarioId);
matricula = funcionario?.matricula;
}
usuario = {
_id: user._id,
matricula: user.matricula,
matricula: matricula || "",
nome: user.nome,
};
}

View File

@@ -78,10 +78,15 @@ export const listarAtividades = query({
const atividadesComUsuarios = await Promise.all(
atividades.map(async (atividade) => {
const usuario = await ctx.db.get(atividade.usuarioId);
let matricula = "N/A";
if (usuario?.funcionarioId) {
const funcionario = await ctx.db.get(usuario.funcionarioId);
matricula = funcionario?.matricula || "N/A";
}
return {
...atividade,
usuarioNome: usuario?.nome || "Usuário Desconhecido",
usuarioMatricula: usuario?.matricula || "N/A",
usuarioMatricula: matricula,
};
})
);
@@ -157,10 +162,15 @@ export const obterHistoricoRecurso = query({
const atividadesComUsuarios = await Promise.all(
atividades.map(async (atividade) => {
const usuario = await ctx.db.get(atividade.usuarioId);
let matricula = "N/A";
if (usuario?.funcionarioId) {
const funcionario = await ctx.db.get(usuario.funcionarioId);
matricula = funcionario?.matricula || "N/A";
}
return {
...atividade,
usuarioNome: usuario?.nome || "Usuário Desconhecido",
usuarioMatricula: usuario?.matricula || "N/A",
usuarioMatricula: matricula,
};
})
);

View File

@@ -30,18 +30,19 @@ export default defineSchema({
simboloId: v.id("simbolos"),
simboloTipo: simboloTipo,
gestorId: v.optional(v.id("usuarios")),
statusFerias: v.optional(v.union(
v.literal("ativo"),
v.literal("em_ferias")
)),
statusFerias: v.optional(
v.union(v.literal("ativo"), v.literal("em_ferias"))
),
// Regime de trabalho (para cálculo correto de férias)
regimeTrabalho: v.optional(v.union(
regimeTrabalho: v.optional(
v.union(
v.literal("clt"), // CLT - Consolidação das Leis do Trabalho
v.literal("estatutario_pe"), // Servidor Público Estadual de Pernambuco
v.literal("estatutario_federal"), // Servidor Público Federal
v.literal("estatutario_municipal") // Servidor Público Municipal
)),
)
),
// Dados Pessoais Adicionais (opcionais)
nomePai: v.optional(v.string()),
@@ -191,10 +192,7 @@ export default defineSchema({
licencas: defineTable({
funcionarioId: v.id("funcionarios"),
tipo: v.union(
v.literal("maternidade"),
v.literal("paternidade")
),
tipo: v.union(v.literal("maternidade"), v.literal("paternidade")),
dataInicio: v.string(),
dataFim: v.string(),
documentoId: v.optional(v.id("_storage")),
@@ -237,11 +235,15 @@ export default defineSchema({
data: v.number(),
usuarioId: v.id("usuarios"),
acao: v.string(),
periodosAnteriores: v.optional(v.array(v.object({
periodosAnteriores: v.optional(
v.array(
v.object({
dataInicio: v.string(),
dataFim: v.string(),
diasCorridos: v.number(),
}))),
})
)
),
})
)
),
@@ -343,7 +345,6 @@ export default defineSchema({
// Sistema de Autenticação e Controle de Acesso
usuarios: defineTable({
matricula: v.string(),
senhaHash: v.string(), // Senha criptografada com bcrypt
nome: v.string(),
email: v.string(),
@@ -380,7 +381,6 @@ export default defineSchema({
notificacoesAtivadas: v.optional(v.boolean()),
somNotificacao: v.optional(v.boolean()),
})
.index("by_matricula", ["matricula"])
.index("by_email", ["email"])
.index("by_role", ["roleId"])
.index("by_ativo", ["ativo"])
@@ -663,8 +663,7 @@ export default defineSchema({
mensagensPorMinuto: v.optional(v.number()),
tempoRespostaMedio: v.optional(v.number()),
errosCount: v.optional(v.number()),
})
.index("by_timestamp", ["timestamp"]),
}).index("by_timestamp", ["timestamp"]),
alertConfigurations: defineTable({
metricName: v.string(),
@@ -681,8 +680,7 @@ export default defineSchema({
notifyByChat: v.boolean(),
createdBy: v.id("usuarios"),
lastModified: v.number(),
})
.index("by_enabled", ["enabled"]),
}).index("by_enabled", ["enabled"]),
alertHistory: defineTable({
configId: v.id("alertConfigurations"),

View File

@@ -316,7 +316,6 @@ export const seedDatabase = internalMutation({
const senhaInicial = await hashPassword("Mudar@123");
await ctx.db.insert("usuarios", {
matricula: funcionario.matricula,
senhaHash: senhaInicial,
nome: funcionario.nome,
email: funcionario.email,

View File

@@ -4,6 +4,21 @@ import { hashPassword, generateToken } from "./auth/utils";
import { registrarAtividade } from "./logsAtividades";
import { Id, Doc } from "./_generated/dataModel";
import { api } from "./_generated/api";
import type { QueryCtx } from "./_generated/server";
/**
* Helper para obter a matrícula do usuário (do funcionário se houver)
*/
async function obterMatriculaUsuario(
ctx: QueryCtx,
usuario: Doc<"usuarios">
): Promise<string | undefined> {
if (usuario.funcionarioId) {
const funcionario = await ctx.db.get(usuario.funcionarioId);
return funcionario?.matricula;
}
return undefined;
}
/**
* Associar funcionário a um usuário
@@ -30,8 +45,11 @@ export const associarFuncionario = mutation({
.first();
if (usuarioExistente && usuarioExistente._id !== args.usuarioId) {
const matricula = await obterMatriculaUsuario(ctx, usuarioExistente);
throw new Error(
`Este funcionário já está associado ao usuário: ${usuarioExistente.nome} (${usuarioExistente.matricula})`
`Este funcionário já está associado ao usuário: ${
usuarioExistente.nome
}${matricula ? ` (${matricula})` : ""}`
);
}
@@ -66,7 +84,6 @@ export const desassociarFuncionario = mutation({
*/
export const criar = mutation({
args: {
matricula: v.string(),
nome: v.string(),
email: v.string(),
roleId: v.id("roles"),
@@ -78,16 +95,6 @@ export const criar = mutation({
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
// Verificar se matrícula já existe
const existente = await ctx.db
.query("usuarios")
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
.first();
if (existente) {
return { sucesso: false as const, erro: "Matrícula já cadastrada" };
}
// Verificar se email já existe
const emailExistente = await ctx.db
.query("usuarios")
@@ -103,7 +110,6 @@ export const criar = mutation({
// Criar usuário
const usuarioId = await ctx.db.insert("usuarios", {
matricula: args.matricula,
senhaHash,
nome: args.nome,
email: args.email,
@@ -194,9 +200,17 @@ export const listar = query({
handler: async (ctx, args) => {
let usuarios = await ctx.db.query("usuarios").collect();
// Filtrar por matrícula
// Filtrar por matrícula (buscar no funcionário)
if (args.matricula) {
usuarios = usuarios.filter((u) => u.matricula.includes(args.matricula!));
const usuariosComMatricula = await Promise.all(
usuarios.map(async (u) => {
const matricula = await obterMatriculaUsuario(ctx, u);
return { usuario: u, matricula };
})
);
usuarios = usuariosComMatricula
.filter(({ matricula }) => matricula?.includes(args.matricula!))
.map(({ usuario }) => usuario);
}
// Filtrar por ativo
@@ -206,7 +220,11 @@ export const listar = query({
// Buscar roles e funcionários
const resultado = [];
const usuariosSemRole: Array<{ nome: string; matricula: string; roleId: Id<"roles"> }> = [];
const usuariosSemRole: Array<{
nome: string;
matricula: string;
roleId: Id<"roles">;
}> = [];
for (const usuario of usuarios) {
try {
@@ -214,9 +232,10 @@ export const listar = query({
// Se a role não existe, criar uma role de erro mas ainda incluir o usuário
if (!role) {
const matricula = await obterMatriculaUsuario(ctx, usuario);
usuariosSemRole.push({
nome: usuario.nome,
matricula: usuario.matricula,
matricula: matricula || "N/A",
roleId: usuario.roleId,
});
@@ -240,14 +259,19 @@ export const listar = query({
};
}
} catch (error) {
console.error(`Erro ao buscar funcionário ${usuario.funcionarioId} para usuário ${usuario._id}:`, error);
console.error(
`Erro ao buscar funcionário ${usuario.funcionarioId} para usuário ${usuario._id}:`,
error
);
}
}
const matriculaUsuario = await obterMatriculaUsuario(ctx, usuario);
// Criar role de erro (sem _creationTime pois a role não existe)
resultado.push({
_id: usuario._id,
matricula: usuario.matricula,
matricula: matriculaUsuario,
nome: usuario.nome,
email: usuario.email,
ativo: usuario.ativo,
@@ -294,7 +318,10 @@ export const listar = query({
};
}
} catch (error) {
console.error(`Erro ao buscar funcionário ${usuario.funcionarioId} para usuário ${usuario._id}:`, error);
console.error(
`Erro ao buscar funcionário ${usuario.funcionarioId} para usuário ${usuario._id}:`,
error
);
}
}
@@ -305,14 +332,18 @@ export const listar = query({
nome: role.nome,
nivel: role.nivel,
...(role.criadoPor !== undefined && { criadoPor: role.criadoPor }),
...(role.customizado !== undefined && { customizado: role.customizado }),
...(role.customizado !== undefined && {
customizado: role.customizado,
}),
...(role.editavel !== undefined && { editavel: role.editavel }),
...(role.setor !== undefined && { setor: role.setor }),
};
const matriculaUsuario = await obterMatriculaUsuario(ctx, usuario);
resultado.push({
_id: usuario._id,
matricula: usuario.matricula,
matricula: matriculaUsuario,
nome: usuario.nome,
email: usuario.email,
ativo: usuario.ativo,
@@ -334,7 +365,12 @@ export const listar = query({
if (usuariosSemRole.length > 0) {
console.warn(
`⚠️ Encontrados ${usuariosSemRole.length} usuário(s) com perfil ausente:`,
usuariosSemRole.map((u) => `${u.nome} (${u.matricula}) - RoleID: ${u.roleId}`)
usuariosSemRole.map(
(u) =>
`${u.nome}${
u.matricula !== "N/A" ? ` (${u.matricula})` : ""
} - RoleID: ${u.roleId}`
)
);
}
@@ -559,7 +595,9 @@ export const atualizarPerfil = mutation({
}
// Atualizar apenas os campos fornecidos
const updates: Partial<Doc<"usuarios">> & { atualizadoEm: number } = { atualizadoEm: Date.now() };
const updates: Partial<Doc<"usuarios">> & { atualizadoEm: number } = {
atualizadoEm: Date.now(),
};
if (args.avatar !== undefined) updates.avatar = args.avatar;
if (args.fotoPerfil !== undefined) updates.fotoPerfil = args.fotoPerfil;
@@ -591,7 +629,7 @@ export const obterPerfil = query({
_id: v.id("usuarios"),
nome: v.string(),
email: v.string(),
matricula: v.string(),
matricula: v.optional(v.string()),
funcionarioId: v.optional(v.id("funcionarios")),
avatar: v.optional(v.string()),
fotoPerfil: v.optional(v.id("_storage")),
@@ -675,11 +713,13 @@ export const obterPerfil = query({
fotoPerfilUrl = await ctx.storage.getUrl(usuarioAtual.fotoPerfil);
}
const matricula = await obterMatriculaUsuario(ctx, usuarioAtual);
return {
_id: usuarioAtual._id,
nome: usuarioAtual.nome,
email: usuarioAtual.email,
matricula: usuarioAtual.matricula,
matricula: matricula || undefined,
funcionarioId: usuarioAtual.funcionarioId,
avatar: usuarioAtual.avatar,
fotoPerfil: usuarioAtual.fotoPerfil,
@@ -735,11 +775,13 @@ export const listarParaChat = query({
fotoPerfilUrl = await ctx.storage.getUrl(usuario.fotoPerfil);
}
const matricula = await obterMatriculaUsuario(ctx, usuario);
return {
_id: usuario._id,
nome: usuario.nome,
email: usuario.email,
matricula: usuario.matricula || undefined,
matricula: matricula || undefined,
avatar: usuario.avatar,
fotoPerfil: usuario.fotoPerfil,
fotoPerfilUrl,
@@ -1035,7 +1077,6 @@ export const editarUsuario = mutation({
*/
export const criarAdminMaster = mutation({
args: {
matricula: v.string(),
nome: v.string(),
email: v.string(),
senha: v.optional(v.string()),
@@ -1074,32 +1115,9 @@ export const criarAdminMaster = mutation({
};
}
// Se já existir usuário por matrícula, promove/atualiza
const existentePorMatricula = await ctx.db
.query("usuarios")
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
.first();
const senhaTemporaria = args.senha || gerarSenhaTemporaria();
const senhaHash = await hashPassword(senhaTemporaria);
if (existentePorMatricula) {
await ctx.db.patch(existentePorMatricula._id, {
nome: args.nome,
email: args.email,
senhaHash,
roleId: roleTIMaster._id,
ativo: true,
primeiroAcesso: true,
atualizadoEm: Date.now(),
});
return {
sucesso: true as const,
usuarioId: existentePorMatricula._id,
senhaTemporaria,
};
}
// Verificar se email já existe
const existentePorEmail = await ctx.db
.query("usuarios")
@@ -1108,7 +1126,6 @@ export const criarAdminMaster = mutation({
if (existentePorEmail) {
// Promove usuário existente por email
await ctx.db.patch(existentePorEmail._id, {
matricula: args.matricula,
nome: args.nome,
senhaHash,
roleId: roleTIMaster._id,
@@ -1125,7 +1142,6 @@ export const criarAdminMaster = mutation({
// Criar novo usuário TI Master
const usuarioId = await ctx.db.insert("usuarios", {
matricula: args.matricula,
senhaHash,
nome: args.nome,
email: args.email,
@@ -1194,7 +1210,6 @@ export const excluirUsuarioLogico = mutation({
*/
export const criarUsuarioCompleto = mutation({
args: {
matricula: v.string(),
nome: v.string(),
email: v.string(),
roleId: v.id("roles"),
@@ -1212,16 +1227,6 @@ export const criarUsuarioCompleto = mutation({
v.object({ sucesso: v.literal(false), erro: v.string() })
),
handler: async (ctx, args) => {
// Verificar se matrícula já existe
const existente = await ctx.db
.query("usuarios")
.withIndex("by_matricula", (q) => q.eq("matricula", args.matricula))
.first();
if (existente) {
return { sucesso: false as const, erro: "Matrícula já cadastrada" };
}
// Verificar se email já existe
const emailExistente = await ctx.db
.query("usuarios")
@@ -1238,7 +1243,6 @@ export const criarUsuarioCompleto = mutation({
// Criar usuário
const usuarioId = await ctx.db.insert("usuarios", {
matricula: args.matricula,
senhaHash,
nome: args.nome,
email: args.email,
@@ -1256,7 +1260,7 @@ export const criarUsuarioCompleto = mutation({
args.criadoPorId,
"criar",
"usuarios",
JSON.stringify({ usuarioId, matricula: args.matricula, nome: args.nome }),
JSON.stringify({ usuarioId, nome: args.nome }),
usuarioId
);
@@ -1272,7 +1276,6 @@ export const criarUsuarioCompleto = mutation({
*/
export const criarAdminPadrao = mutation({
args: {
matricula: v.optional(v.string()),
nome: v.optional(v.string()),
email: v.optional(v.string()),
senha: v.optional(v.string()),
@@ -1282,7 +1285,6 @@ export const criarAdminPadrao = mutation({
usuarioId: v.optional(v.id("usuarios")),
}),
handler: async (ctx, args) => {
const matricula = args.matricula ?? "0000";
const nome = args.nome ?? "Administrador Geral";
const email = args.email ?? "admin@sgse.pe.gov.br";
const senha = args.senha ?? "Admin@123";
@@ -1306,12 +1308,7 @@ export const criarAdminPadrao = mutation({
if (!roleAdmin) return { sucesso: false };
// Verificar se já existe por matrícula ou email
const existentePorMatricula = await ctx.db
.query("usuarios")
.withIndex("by_matricula", (q) => q.eq("matricula", matricula))
.first();
// Verificar se já existe por email
const existentePorEmail = await ctx.db
.query("usuarios")
.withIndex("by_email", (q) => q.eq("email", email))
@@ -1319,10 +1316,8 @@ export const criarAdminPadrao = mutation({
const senhaHash = await hashPassword(senha);
if (existentePorMatricula || existentePorEmail) {
const alvo = existentePorMatricula ?? existentePorEmail!;
await ctx.db.patch(alvo._id, {
matricula,
if (existentePorEmail) {
await ctx.db.patch(existentePorEmail._id, {
nome,
email,
senhaHash,
@@ -1331,11 +1326,10 @@ export const criarAdminPadrao = mutation({
primeiroAcesso: false,
atualizadoEm: Date.now(),
});
return { sucesso: true, usuarioId: alvo._id };
return { sucesso: true, usuarioId: existentePorEmail._id };
}
const usuarioId = await ctx.db.insert("usuarios", {
matricula,
senhaHash,
nome,
email,

View File

@@ -3,7 +3,7 @@ import { v } from "convex/values";
import { Id, Doc } from "./_generated/dataModel";
/**
* Verificar duplicatas de matrícula
* Verificar duplicatas de matrícula (agora busca do funcionário associado)
*/
export const verificarDuplicatas = query({
args: {},
@@ -23,18 +23,27 @@ export const verificarDuplicatas = query({
handler: async (ctx) => {
const usuarios = await ctx.db.query("usuarios").collect();
// Agrupar por matrícula
const gruposPorMatricula = usuarios.reduce((acc, usuario) => {
if (!acc[usuario.matricula]) {
acc[usuario.matricula] = [];
// Agrupar por matrícula do funcionário associado
const gruposPorMatricula: Record<string, Array<{ _id: Id<"usuarios">; nome: string; email: string }>> = {};
for (const usuario of usuarios) {
let matricula: string | undefined = undefined;
if (usuario.funcionarioId) {
const funcionario = await ctx.db.get(usuario.funcionarioId);
matricula = funcionario?.matricula;
}
acc[usuario.matricula].push({
if (matricula) {
if (!gruposPorMatricula[matricula]) {
gruposPorMatricula[matricula] = [];
}
gruposPorMatricula[matricula].push({
_id: usuario._id,
nome: usuario.nome,
email: usuario.email || "",
});
return acc;
}, {} as Record<string, Array<{ _id: Id<"usuarios">; nome: string; email: string }>>);
}
}
// Filtrar apenas duplicatas
const duplicatas = Object.entries(gruposPorMatricula)
@@ -50,7 +59,7 @@ export const verificarDuplicatas = query({
});
/**
* Remover duplicatas mantendo apenas o mais recente
* Remover duplicatas mantendo apenas o mais recente (agora busca do funcionário associado)
*/
export const removerDuplicatas = internalMutation({
args: {},
@@ -61,14 +70,23 @@ export const removerDuplicatas = internalMutation({
handler: async (ctx) => {
const usuarios = await ctx.db.query("usuarios").collect();
// Agrupar por matrícula
const gruposPorMatricula = usuarios.reduce((acc, usuario) => {
if (!acc[usuario.matricula]) {
acc[usuario.matricula] = [];
// Agrupar por matrícula do funcionário associado
const gruposPorMatricula: Record<string, Doc<"usuarios">[]> = {};
for (const usuario of usuarios) {
let matricula: string | undefined = undefined;
if (usuario.funcionarioId) {
const funcionario = await ctx.db.get(usuario.funcionarioId);
matricula = funcionario?.matricula;
}
if (matricula) {
if (!gruposPorMatricula[matricula]) {
gruposPorMatricula[matricula] = [];
}
gruposPorMatricula[matricula].push(usuario);
}
}
acc[usuario.matricula].push(usuario);
return acc;
}, {} as Record<string, Doc<"usuarios">[]>);
let removidos = 0;
const matriculasDuplicadas: string[] = [];