Ajustes gerais #57

Merged
deyvisonwanderley merged 49 commits from ajustes_gerais into master 2025-12-09 18:13:50 +00:00
214 changed files with 31694 additions and 25224 deletions
Showing only changes of commit a3d9e782af - Show all commits

View File

@@ -46,7 +46,7 @@
} from '$lib/utils/chamados';
import { useConvexWithAuth } from '$lib/hooks/useConvexWithAuth';
import type { Doc } from '@sgse-app/backend/convex/_generated/dataModel';
import { temasDisponiveis, aplicarTema, type Tema } from '$lib/utils/temas';
import { temasDisponiveis, aplicarTema } from '$lib/utils/temas';
const client = useConvexClient();
// @ts-expect-error - Convex types issue with getCurrentUser
@@ -128,6 +128,9 @@
const funcionarioIdDisponivel = $derived(currentUser?.data?.funcionarioId ?? null);
const gestorIdDisponivel = $derived(currentUser?.data?._id ?? null);
// Qualquer usuário com funcionarioId é considerado funcionário
const isFuncionario = $derived(!!funcionarioIdDisponivel);
// Verificar autenticação antes de executar queries
const usuarioAutenticado = $derived(
currentUser?.data !== null && currentUser?.data !== undefined
@@ -818,8 +821,8 @@
<FileCheck class="h-5 w-5" strokeWidth={2} />
Meus Chamados
</button>
{#if ehGestor}
{#if isFuncionario}
<!-- Funcionário: solicitar férias -->
<button
type="button"
role="tab"
@@ -830,6 +833,7 @@
Minhas Férias
</button>
<!-- Funcionário: solicitar ausências -->
<button
type="button"
role="tab"
@@ -839,41 +843,42 @@
<Clock class="h-5 w-5" strokeWidth={2} />
Minhas Ausências
</button>
{/if}
{#if ehGestor}
<button
type="button"
role="tab"
class={`tab tab-lg gap-2 font-semibold transition-all duration-300 ${abaAtiva === 'aprovar-ferias' ? 'tab-active scale-105 bg-gradient-to-r from-green-600 to-emerald-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
onclick={() => (abaAtiva = 'aprovar-ferias')}
>
<CheckCircle2 class="h-5 w-5" strokeWidth={2} />
Aprovar Férias
{#if (solicitacoesSubordinados || []).filter((s) => s.status === 'aguardando_aprovacao').length > 0}
<span class="badge badge-error badge-sm ml-1 animate-pulse">
{(solicitacoesSubordinados || []).filter(
(s) => s.status === 'aguardando_aprovacao'
).length}
</span>
{/if}
</button>
{#if ehGestor}
<!-- Gestor: aprovar férias -->
<button
type="button"
role="tab"
class={`tab tab-lg gap-2 font-semibold transition-all duration-300 ${abaAtiva === 'aprovar-ferias' ? 'tab-active scale-105 bg-gradient-to-r from-green-600 to-emerald-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
onclick={() => (abaAtiva = 'aprovar-ferias')}
>
<CheckCircle2 class="h-5 w-5" strokeWidth={2} />
Aprovar Férias
{#if (solicitacoesSubordinados || []).filter((s) => s.status === 'aguardando_aprovacao').length > 0}
<span class="badge badge-error badge-sm ml-1 animate-pulse">
{(solicitacoesSubordinados || []).filter((s) => s.status === 'aguardando_aprovacao')
.length}
</span>
{/if}
</button>
<button
type="button"
role="tab"
class={`tab tab-lg gap-2 font-semibold transition-all duration-300 ${abaAtiva === 'aprovar-ausencias' ? 'tab-active scale-105 bg-gradient-to-r from-orange-600 to-amber-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
onclick={() => (abaAtiva = 'aprovar-ausencias')}
>
<Clock class="h-5 w-5" strokeWidth={2} />
Aprovar Ausências
{#if (ausenciasSubordinados || []).filter((a) => a.status === 'aguardando_aprovacao').length > 0}
<span class="badge badge-error badge-sm ml-1 animate-pulse">
{(ausenciasSubordinados || []).filter((a) => a.status === 'aguardando_aprovacao')
.length}
</span>
{/if}
</button>
{/if}
<!-- Gestor: aprovar ausências -->
<button
type="button"
role="tab"
class={`tab tab-lg gap-2 font-semibold transition-all duration-300 ${abaAtiva === 'aprovar-ausencias' ? 'tab-active scale-105 bg-gradient-to-r from-orange-600 to-amber-600 text-white shadow-lg' : 'hover:bg-base-100'}`}
onclick={() => (abaAtiva = 'aprovar-ausencias')}
>
<Clock class="h-5 w-5" strokeWidth={2} />
Aprovar Ausências
{#if (ausenciasSubordinados || []).filter((a) => a.status === 'aguardando_aprovacao').length > 0}
<span class="badge badge-error badge-sm ml-1 animate-pulse">
{(ausenciasSubordinados || []).filter((a) => a.status === 'aguardando_aprovacao')
.length}
</span>
{/if}
</button>
{/if}
<button
@@ -1217,7 +1222,7 @@
>
{#if setoresQuery?.data && setoresQuery.data.length > 0}
<div class="mt-2 flex flex-wrap gap-2">
{#each setoresQuery.data as setor}
{#each setoresQuery.data as setor (setor._id)}
<div
class="badge badge-lg font-semibold shadow-sm"
style="background-color: rgba(20, 184, 166, 0.1); border-color: rgba(20, 184, 166, 0.3); color: rgb(15, 118, 110);"
@@ -1229,8 +1234,6 @@
</div>
{/each}
</div>
{:else if setoresQuery?.isLoading}
<p class="text-base-content/50 mt-1 text-sm">Carregando...</p>
{:else}
<p class="text-base-content/50 mt-1 text-sm">Nenhum setor atribuído</p>
{/if}

View File

@@ -22,18 +22,19 @@
import { ptBR } from 'date-fns/locale';
import { toast } from 'svelte-sonner';
type StatusFiltro = 'pendente' | 'em_analise' | 'concluida' | 'rejeitada' | null;
type StatusFiltro = '' | 'pendente' | 'em_analise' | 'concluida' | 'rejeitada';
type TipoFiltro =
| ''
| 'acesso'
| 'correcao'
| 'exclusao'
| 'portabilidade'
| 'revogacao_consentimento'
| 'informacao_compartilhamento'
| null;
| 'informacao_compartilhamento';
let statusFiltro = $state<StatusFiltro>(null);
let tipoFiltro = $state<TipoFiltro>(null);
// '' = Todos (sem filtro)
let statusFiltro = $state<StatusFiltro>('');
let tipoFiltro = $state<TipoFiltro>('');
let termoBusca = $state('');
const client = useConvexClient();
@@ -221,7 +222,7 @@
<span class="label-text font-semibold">Status</span>
</label>
<select bind:value={statusFiltro} class="select select-bordered">
<option value={null}>Todos</option>
<option value="">Todos</option>
<option value="pendente">Pendente</option>
<option value="em_analise">Em Análise</option>
<option value="concluida">Concluída</option>
@@ -234,7 +235,7 @@
<span class="label-text font-semibold">Tipo</span>
</label>
<select bind:value={tipoFiltro} class="select select-bordered">
<option value={null}>Todos</option>
<option value="">Todos</option>
<option value="acesso">Acesso</option>
<option value="correcao">Correção</option>
<option value="exclusao">Exclusão</option>

View File

@@ -4,6 +4,7 @@ import { getCurrentUserFunction } from './auth';
import { Id, Doc } from './_generated/dataModel';
import type { QueryCtx, MutationCtx } from './_generated/server';
import { registrarAtividade } from './logsAtividades';
import { api } from './_generated/api';
/**
* Verificar se usuário aceitou o termo de consentimento
@@ -275,6 +276,43 @@ export const criarSolicitacao = mutation({
solicitacaoId.toString()
);
// Notificações (email + opcional chat) para o titular
if (usuario.email) {
let urlSistema = process.env.FRONTEND_URL || 'http://localhost:5173';
if (!urlSistema.match(/^https?:\/\//i)) {
urlSistema = `http://${urlSistema}`;
}
const tipoSolicitacaoLabelMap: Record<string, string> = {
acesso: 'Acesso aos Dados',
correcao: 'Correção de Dados',
exclusao: 'Exclusão de Dados',
portabilidade: 'Portabilidade dos Dados',
revogacao_consentimento: 'Revogação de Consentimento',
informacao_compartilhamento: 'Informação sobre Compartilhamento'
};
const tipoSolicitacaoLabel = tipoSolicitacaoLabelMap[args.tipo] ?? args.tipo;
// Email usando template LGPD
try {
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
destinatario: usuario.email,
destinatarioId: usuario._id,
templateCodigo: 'lgpd_solicitacao_criada',
variaveis: {
nomeTitular: usuario.nome,
tipoSolicitacaoLabel,
prazoResposta: new Date(prazoResposta).toLocaleDateString('pt-BR'),
urlPortalLGPD: `${urlSistema}/privacidade/meus-dados`
},
enviadoPor: usuario._id
});
} catch (error) {
console.error('Erro ao agendar email lgpd_solicitacao_criada:', error);
}
}
return { sucesso: true, solicitacaoId };
}
});
@@ -586,6 +624,7 @@ export const responderSolicitacao = mutation({
throw new Error('Solicitação não encontrada');
}
// Atualizar resposta da solicitação
await ctx.db.patch(args.solicitacaoId, {
status: args.status,
resposta: args.resposta,
@@ -594,6 +633,48 @@ export const responderSolicitacao = mutation({
respondidoEm: Date.now()
});
// Se for uma solicitação de "Revogar Consentimento" concluída,
// revogar todos os consentimentos ativos do titular que fez a solicitação.
if (solicitacao.tipo === 'revogacao_consentimento' && args.status === 'concluida') {
// Garantir que temos o titular associado
if (!solicitacao.usuarioId) {
throw new Error(
'Solicitação de revogação de consentimento sem usuário associado. Verifique os dados.'
);
}
// Buscar consentimentos ativos do usuário
const consentimentosAtivos = await ctx.db
.query('consentimentos')
.withIndex('by_usuario', (q) => q.eq('usuarioId', solicitacao.usuarioId))
.filter((q) => q.eq(q.field('aceito'), true))
.collect();
for (const consentimento of consentimentosAtivos) {
// Pular consentimentos já revogados por segurança
if (consentimento.revogadoEm) continue;
await ctx.db.patch(consentimento._id, {
revogadoEm: Date.now(),
revogadoPor: usuario._id
});
// Registrar atividade individual por consentimento revogado
await registrarAtividade(
ctx,
usuario._id,
'revogar_consentimento_por_solicitacao',
'consentimentos',
JSON.stringify({
tipo: consentimento.tipo,
origem: 'solicitacao_lgpd',
solicitacaoId: args.solicitacaoId
}),
consentimento._id.toString()
);
}
}
// Log de atividade
await registrarAtividade(
ctx,
@@ -604,6 +685,107 @@ export const responderSolicitacao = mutation({
args.solicitacaoId.toString()
);
// Notificações para o titular (email + chat)
const usuarioTitular = await ctx.db.get(solicitacao.usuarioId);
if (usuarioTitular) {
let urlSistema = process.env.FRONTEND_URL || 'http://localhost:5173';
if (!urlSistema.match(/^https?:\/\//i)) {
urlSistema = `http://${urlSistema}`;
}
const tipoSolicitacaoLabelMap: Record<string, string> = {
acesso: 'Acesso aos Dados',
correcao: 'Correção de Dados',
exclusao: 'Exclusão de Dados',
portabilidade: 'Portabilidade dos Dados',
revogacao_consentimento: 'Revogação de Consentimento',
informacao_compartilhamento: 'Informação sobre Compartilhamento'
};
const statusLabelMap: Record<string, string> = {
concluida: 'Concluída',
rejeitada: 'Rejeitada',
em_analise: 'Em Análise'
};
const tipoSolicitacaoLabel = tipoSolicitacaoLabelMap[solicitacao.tipo] ?? solicitacao.tipo;
const statusLabel = statusLabelMap[args.status] ?? args.status;
const resumoResposta = args.resposta.length > 500 ? `${args.resposta.slice(0, 500)}...` : args.resposta;
// Escolher template conforme o tipo
const tipoToTemplate: Record<string, string> = {
acesso: 'lgpd_resposta_acesso',
correcao: 'lgpd_resposta_correcao',
exclusao: 'lgpd_resposta_exclusao',
portabilidade: 'lgpd_resposta_portabilidade',
revogacao_consentimento: 'lgpd_resposta_revogacao_consentimento',
informacao_compartilhamento: 'lgpd_resposta_informacao_compartilhamento'
};
const templateCodigo = tipoToTemplate[solicitacao.tipo] ?? 'lgpd_resposta_acesso';
// Email para o titular
if (usuarioTitular.email) {
try {
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
destinatario: usuarioTitular.email,
destinatarioId: usuarioTitular._id,
templateCodigo,
variaveis: {
nomeTitular: usuarioTitular.nome,
tipoSolicitacaoLabel,
statusLabel,
resumoResposta,
urlPortalLGPD: `${urlSistema}/privacidade/meus-dados`
},
enviadoPor: usuario._id
});
} catch (error) {
console.error(`Erro ao agendar email ${templateCodigo}:`, error);
}
}
// Mensagem simples no chat entre TI (respondente) e o titular
try {
// Buscar conversa individual existente
const conversas = await ctx.db
.query('conversas')
.filter((q) => q.eq(q.field('tipo'), 'individual'))
.collect();
let conversaId: Id<'conversas'> | null = null;
for (const conversa of conversas) {
if (
conversa.participantes.length === 2 &&
conversa.participantes.includes(usuario._id) &&
conversa.participantes.includes(usuarioTitular._id)
) {
conversaId = conversa._id;
break;
}
}
if (!conversaId) {
conversaId = await ctx.db.insert('conversas', {
tipo: 'individual',
participantes: [usuario._id, usuarioTitular._id],
criadoPor: usuario._id,
criadoEm: Date.now()
});
}
await ctx.db.insert('mensagens', {
conversaId,
remetenteId: usuario._id,
tipo: 'texto',
conteudo: `Respondi sua solicitação LGPD (${tipoSolicitacaoLabel}) com status ${statusLabel}. Resumo: ${resumoResposta}`,
enviadaEm: Date.now()
});
} catch (error) {
console.error('Erro ao criar mensagem de chat para resposta LGPD:', error);
}
}
return { sucesso: true };
}
});

View File

@@ -549,6 +549,107 @@ export const criarTemplatesPadrao = mutation({
categoria: 'email' as const,
tags: ['monitoramento', 'alerta', 'sistema', 'ti']
},
// ===================== LGPD =====================
{
codigo: 'lgpd_solicitacao_criada',
nome: 'LGPD - Solicitação Criada',
titulo: 'Recebemos sua solicitação LGPD ({{tipoSolicitacaoLabel}})',
corpo:
'Olá {{nomeTitular}},\n\n' +
'Recebemos sua solicitação LGPD do tipo "{{tipoSolicitacaoLabel}}".\n\n' +
'Prazo estimado para resposta: até {{prazoResposta}}.\n\n' +
'Você pode acompanhar o andamento acessando: {{urlPortalLGPD}}.\n\n' +
'Equipe de Proteção de Dados / TI.',
variaveis: ['nomeTitular', 'tipoSolicitacaoLabel', 'prazoResposta', 'urlPortalLGPD'],
categoria: 'email' as const,
tags: ['lgpd', 'solicitacao', 'dados_pessoais']
},
{
codigo: 'lgpd_resposta_acesso',
nome: 'LGPD - Resposta Acesso',
titulo: 'Resposta à sua solicitação LGPD - Acesso aos Dados',
corpo:
'Olá {{nomeTitular}},\n\n' +
'Sua solicitação LGPD de Acesso aos Dados foi marcada como {{statusLabel}}.\n\n' +
'Resumo da resposta:\n{{resumoResposta}}\n\n' +
'Para mais detalhes, acesse: {{urlPortalLGPD}}.\n\n' +
'Equipe de Proteção de Dados / TI.',
variaveis: ['nomeTitular', 'statusLabel', 'resumoResposta', 'urlPortalLGPD'],
categoria: 'email' as const,
tags: ['lgpd', 'acesso', 'dados_pessoais']
},
{
codigo: 'lgpd_resposta_correcao',
nome: 'LGPD - Resposta Correção',
titulo: 'Resposta à sua solicitação LGPD - Correção de Dados',
corpo:
'Olá {{nomeTitular}},\n\n' +
'Sua solicitação LGPD de Correção de Dados foi marcada como {{statusLabel}}.\n\n' +
'Resumo da resposta:\n{{resumoResposta}}\n\n' +
'Para mais detalhes, acesse: {{urlPortalLGPD}}.\n\n' +
'Equipe de Proteção de Dados / TI.',
variaveis: ['nomeTitular', 'statusLabel', 'resumoResposta', 'urlPortalLGPD'],
categoria: 'email' as const,
tags: ['lgpd', 'correcao', 'dados_pessoais']
},
{
codigo: 'lgpd_resposta_exclusao',
nome: 'LGPD - Resposta Exclusão',
titulo: 'Resposta à sua solicitação LGPD - Exclusão de Dados',
corpo:
'Olá {{nomeTitular}},\n\n' +
'Sua solicitação LGPD de Exclusão de Dados foi marcada como {{statusLabel}}.\n\n' +
'Resumo da resposta:\n{{resumoResposta}}\n\n' +
'Para mais detalhes, acesse: {{urlPortalLGPD}}.\n\n' +
'Equipe de Proteção de Dados / TI.',
variaveis: ['nomeTitular', 'statusLabel', 'resumoResposta', 'urlPortalLGPD'],
categoria: 'email' as const,
tags: ['lgpd', 'exclusao', 'dados_pessoais']
},
{
codigo: 'lgpd_resposta_portabilidade',
nome: 'LGPD - Resposta Portabilidade',
titulo: 'Resposta à sua solicitação LGPD - Portabilidade dos Dados',
corpo:
'Olá {{nomeTitular}},\n\n' +
'Sua solicitação LGPD de Portabilidade dos Dados foi marcada como {{statusLabel}}.\n\n' +
'Resumo da resposta:\n{{resumoResposta}}\n\n' +
'Caso tenha recebido um arquivo anexo, ele contém os dados em formato portável.\n\n' +
'Para mais detalhes, acesse: {{urlPortalLGPD}}.\n\n' +
'Equipe de Proteção de Dados / TI.',
variaveis: ['nomeTitular', 'statusLabel', 'resumoResposta', 'urlPortalLGPD'],
categoria: 'email' as const,
tags: ['lgpd', 'portabilidade', 'dados_pessoais']
},
{
codigo: 'lgpd_resposta_revogacao_consentimento',
nome: 'LGPD - Resposta Revogação de Consentimento',
titulo: 'Confirmação de Revogação de Consentimento',
corpo:
'Olá {{nomeTitular}},\n\n' +
'Sua solicitação LGPD de Revogação de Consentimento foi marcada como {{statusLabel}}.\n\n' +
'Resumo da resposta:\n{{resumoResposta}}\n\n' +
'Todos os consentimentos ativos associados à sua conta foram marcados como revogados a partir desta data.\n\n' +
'Para mais detalhes, acesse: {{urlPortalLGPD}}.\n\n' +
'Equipe de Proteção de Dados / TI.',
variaveis: ['nomeTitular', 'statusLabel', 'resumoResposta', 'urlPortalLGPD'],
categoria: 'email' as const,
tags: ['lgpd', 'revogacao_consentimento', 'dados_pessoais']
},
{
codigo: 'lgpd_resposta_informacao_compartilhamento',
nome: 'LGPD - Resposta Informação sobre Compartilhamento',
titulo: 'Resposta à sua solicitação LGPD - Informação sobre Compartilhamento',
corpo:
'Olá {{nomeTitular}},\n\n' +
'Sua solicitação LGPD de Informação sobre Compartilhamento foi marcada como {{statusLabel}}.\n\n' +
'Resumo da resposta:\n{{resumoResposta}}\n\n' +
'Para mais detalhes, acesse: {{urlPortalLGPD}}.\n\n' +
'Equipe de Proteção de Dados / TI.',
variaveis: ['nomeTitular', 'statusLabel', 'resumoResposta', 'urlPortalLGPD'],
categoria: 'email' as const,
tags: ['lgpd', 'informacao_compartilhamento', 'dados_pessoais']
},
{
codigo: 'ausencia_solicitada',
nome: 'Ausência Solicitada',