feat: implement LGPD compliance features including data request management, consent tracking, and statistics display in the dashboard for enhanced data protection compliance

This commit is contained in:
2025-12-01 22:37:43 -03:00
parent 95c3b48ae6
commit fec5f5c33d
14 changed files with 3231 additions and 5 deletions

View File

@@ -452,7 +452,7 @@
class="link link-hover hover:text-primary transition-colors">Suporte</a class="link link-hover hover:text-primary transition-colors">Suporte</a
> >
<span class="text-base-content/30"></span> <span class="text-base-content/30"></span>
<a href={resolve('/')} class="link link-hover hover:text-primary transition-colors" <a href={resolve('/privacidade')} class="link link-hover hover:text-primary transition-colors"
>Privacidade</a >Privacidade</a
> >
</div> </div>

View File

@@ -0,0 +1,194 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { useQuery, useMutation } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { Shield, CheckCircle, XCircle, Calendar, AlertCircle } from 'lucide-svelte';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { toast } from 'svelte-sonner';
const consentimentos = useQuery(api.lgpd.listarConsentimentos, {});
const revogarConsentimento = useMutation(api.lgpd.revogarConsentimento);
let revogando = $state<string | null>(null);
function getTipoLabel(tipo: string) {
const labels: Record<string, string> = {
termo_uso: 'Termo de Uso',
politica_privacidade: 'Política de Privacidade',
comunicacoes: 'Comunicações',
compartilhamento_dados: 'Compartilhamento de Dados'
};
return labels[tipo] || tipo;
}
async function revogar(tipo: string) {
if (!confirm('Tem certeza que deseja revogar este consentimento?')) {
return;
}
revogando = tipo;
try {
await revogarConsentimento({ tipo: tipo as any });
toast.success('Consentimento revogado com sucesso');
} catch (error: any) {
toast.error(error.message || 'Erro ao revogar consentimento');
} finally {
revogando = null;
}
}
</script>
<div class="container mx-auto px-4 py-8 max-w-4xl">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center gap-4 mb-4">
<div class="p-3 bg-primary/10 rounded-xl">
<Shield class="h-8 w-8 text-primary" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold text-base-content">Preferências de Privacidade</h1>
<p class="text-base-content/60 mt-1">
Gerencie seus consentimentos e preferências de privacidade
</p>
</div>
</div>
</div>
<!-- Consentimentos -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">Meus Consentimentos</h2>
{#if consentimentos === undefined}
<div class="flex justify-center items-center py-10">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if consentimentos.length === 0}
<div class="text-center py-10">
<Shield class="h-16 w-16 text-base-content/30 mx-auto mb-4" />
<p class="text-base-content/60">Nenhum consentimento registrado</p>
</div>
{:else}
<div class="space-y-4">
{#each consentimentos as consentimento}
<div class="border border-base-300 rounded-lg p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
{#if consentimento.aceito && !consentimento.revogadoEm}
<CheckCircle class="h-5 w-5 text-success" />
{:else}
<XCircle class="h-5 w-5 text-error" />
{/if}
<h3 class="font-semibold text-lg">
{getTipoLabel(consentimento.tipo)}
</h3>
{#if consentimento.aceito && !consentimento.revogadoEm}
<span class="badge badge-success">Ativo</span>
{:else}
<span class="badge badge-error">Revogado</span>
{/if}
</div>
<div class="space-y-1 text-sm text-base-content/70">
<div class="flex items-center gap-2">
<Calendar class="h-4 w-4" />
<span>
Aceito em:{' '}
{format(new Date(consentimento.aceitoEm), 'dd/MM/yyyy às HH:mm', {
locale: ptBR
})}
</span>
</div>
<div>
<span class="font-semibold">Versão:</span> {consentimento.versao}
</div>
{#if consentimento.revogadoEm}
<div class="flex items-center gap-2 text-error">
<XCircle class="h-4 w-4" />
<span>
Revogado em:{' '}
{format(
new Date(consentimento.revogadoEm),
'dd/MM/yyyy às HH:mm',
{ locale: ptBR }
)}
</span>
</div>
{/if}
</div>
</div>
{#if consentimento.aceito && !consentimento.revogadoEm}
<button
onclick={() => revogar(consentimento.tipo)}
disabled={revogando === consentimento.tipo}
class="btn btn-sm btn-error"
>
{#if revogando === consentimento.tipo}
<span class="loading loading-spinner loading-xs"></span>
{:else}
Revogar
{/if}
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- Informações -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">Informações Importantes</h2>
<div class="alert alert-warning mb-4">
<AlertCircle class="h-5 w-5" />
<div>
<p class="font-semibold">Atenção ao Revogar Consentimentos</p>
<p class="text-sm">
A revogação de alguns consentimentos pode impedir o acesso a funcionalidades do
sistema que dependem do tratamento de dados pessoais.
</p>
</div>
</div>
<div class="space-y-3 text-sm text-base-content/80">
<p>
<strong>Termo de Uso:</strong> Aceite obrigatório para utilização do sistema. A
revogação pode impedir o acesso.
</p>
<p>
<strong>Política de Privacidade:</strong> Informa como seus dados são tratados. A
revogação não impede o tratamento, mas você pode solicitar exclusão.
</p>
<p>
<strong>Comunicações:</strong> Permite envio de notificações e comunicações do
sistema. A revogação pode limitar informações importantes.
</p>
<p>
<strong>Compartilhamento de Dados:</strong> Permite compartilhamento com terceiros
quando necessário. A revogação pode afetar serviços terceirizados.
</p>
</div>
</div>
</div>
<!-- Links -->
<div class="flex flex-col sm:flex-row gap-4 mt-6">
<a href={resolve('/privacidade')} class="btn btn-outline btn-lg flex-1">
<Shield class="h-5 w-5" />
Ver Política de Privacidade
</a>
<a href={resolve('/privacidade/meus-dados')} class="btn btn-primary btn-lg flex-1">
<Shield class="h-5 w-5" />
Solicitar Meus Direitos
</a>
</div>
</div>

View File

@@ -0,0 +1,419 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { Shield, FileText, Mail, Phone, Calendar } from 'lucide-svelte';
</script>
<div class="container mx-auto px-4 py-8 max-w-4xl">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center gap-4 mb-4">
<div class="p-3 bg-primary/10 rounded-xl">
<Shield class="h-8 w-8 text-primary" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold text-base-content">Política de Privacidade</h1>
<p class="text-base-content/60 mt-1">
Lei Geral de Proteção de Dados Pessoais (LGPD) - Lei nº 13.709/2018
</p>
</div>
</div>
<div class="flex items-center gap-2 text-sm text-base-content/60">
<Calendar class="h-4 w-4" />
<span>Última atualização: {new Date().toLocaleDateString('pt-BR')}</span>
</div>
</div>
<!-- Conteúdo -->
<div class="prose prose-lg max-w-none">
<!-- Introdução -->
<section class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">1. Introdução</h2>
<p class="text-base-content/80">
A Secretaria de Esportes do Estado de Pernambuco, no exercício de suas atribuições
constitucionais e legais, está comprometida com a proteção dos dados pessoais de seus
servidores, colaboradores e cidadãos, em conformidade com a Lei Geral de Proteção de
Dados Pessoais (LGPD) - Lei nº 13.709/2018.
</p>
<p class="text-base-content/80 mt-4">
Esta Política de Privacidade descreve como coletamos, utilizamos, armazenamos e
protegemos seus dados pessoais no Sistema de Gestão da Secretaria de Esportes (SGSE).
</p>
</div>
</section>
<!-- Dados Coletados -->
<section class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">2. Dados Pessoais Coletados</h2>
<p class="text-base-content/80 mb-4">
O SGSE coleta e processa os seguintes tipos de dados pessoais:
</p>
<div class="space-y-3">
<div class="flex items-start gap-3">
<FileText class="h-5 w-5 text-primary mt-1 flex-shrink-0" />
<div>
<h3 class="font-semibold">Dados de Identificação</h3>
<p class="text-sm text-base-content/70">
Nome completo, CPF, RG, data de nascimento, naturalidade, nacionalidade,
estado civil, filiação (nome do pai e mãe)
</p>
</div>
</div>
<div class="flex items-start gap-3">
<FileText class="h-5 w-5 text-primary mt-1 flex-shrink-0" />
<div>
<h3 class="font-semibold">Dados de Contato</h3>
<p class="text-sm text-base-content/70">
E-mail, telefone, endereço residencial e endereços de marcação de ponto
</p>
</div>
</div>
<div class="flex items-start gap-3">
<FileText class="h-5 w-5 text-primary mt-1 flex-shrink-0" />
<div>
<h3 class="font-semibold">Dados Profissionais</h3>
<p class="text-sm text-base-content/70">
Matrícula, cargo, função, setor, data de admissão, regime de trabalho,
documentos profissionais (CTPS, título eleitor, reservista, PIS)
</p>
</div>
</div>
<div class="flex items-start gap-3">
<FileText class="h-5 w-5 text-primary mt-1 flex-shrink-0" />
<div>
<h3 class="font-semibold">Dados de Saúde</h3>
<p class="text-sm text-base-content/70">
Atestados médicos, licenças de saúde, grupo sanguíneo, fator RH (quando
necessário para atividades específicas)
</p>
</div>
</div>
<div class="flex items-start gap-3">
<FileText class="h-5 w-5 text-primary mt-1 flex-shrink-0" />
<div>
<h3 class="font-semibold">Dados de Acesso</h3>
<p class="text-sm text-base-content/70">
Credenciais de acesso, logs de acesso, endereço IP, histórico de atividades
no sistema
</p>
</div>
</div>
</div>
</div>
</section>
<!-- Finalidade -->
<section class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">3. Finalidade do Tratamento</h2>
<p class="text-base-content/80 mb-4">
Os dados pessoais são tratados para as seguintes finalidades:
</p>
<ul class="list-disc list-inside space-y-2 text-base-content/80">
<li>Gestão de recursos humanos e folha de pagamento</li>
<li>Controle de ponto e registro de jornada de trabalho</li>
<li>Gestão de férias, ausências e licenças</li>
<li>Processamento de atestados médicos e licenças de saúde</li>
<li>Gestão de chamados e suporte técnico</li>
<li>Comunicação interna e notificações</li>
<li>Cumprimento de obrigações legais e regulatórias</li>
<li>Execução de políticas públicas</li>
<li>Segurança e prevenção de fraudes</li>
<li>Auditoria e controle interno</li>
</ul>
</div>
</section>
<!-- Base Legal -->
<section class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">4. Base Legal do Tratamento</h2>
<p class="text-base-content/80 mb-4">
O tratamento de dados pessoais no SGSE fundamenta-se nas seguintes bases legais,
conforme previsto no Art. 7º da LGPD:
</p>
<div class="space-y-3">
<div class="bg-primary/5 p-4 rounded-lg">
<h3 class="font-semibold text-primary mb-2">I. Execução de Políticas Públicas</h3>
<p class="text-sm text-base-content/70">
Art. 7º, II - Para a execução de políticas públicas previstas em leis ou
regulamentos
</p>
</div>
<div class="bg-primary/5 p-4 rounded-lg">
<h3 class="font-semibold text-primary mb-2">II. Cumprimento de Obrigação Legal</h3>
<p class="text-sm text-base-content/70">
Art. 7º, I - Para cumprimento de obrigação legal ou regulatória pelo controlador
</p>
</div>
<div class="bg-primary/5 p-4 rounded-lg">
<h3 class="font-semibold text-primary mb-2">III. Execução de Contrato</h3>
<p class="text-sm text-base-content/70">
Art. 7º, V - Para a execução de contrato ou de procedimentos preliminares
relacionados a contrato do qual seja parte o titular
</p>
</div>
<div class="bg-primary/5 p-4 rounded-lg">
<h3 class="font-semibold text-primary mb-2">IV. Proteção da Vida e Saúde</h3>
<p class="text-sm text-base-content/70">
Art. 7º, VI e VII - Para a proteção da vida ou da incolumidade física do titular
ou de terceiro, e para a tutela da saúde
</p>
</div>
</div>
</div>
</section>
<!-- Compartilhamento -->
<section class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">5. Compartilhamento de Dados</h2>
<p class="text-base-content/80 mb-4">
Os dados pessoais podem ser compartilhados com:
</p>
<ul class="list-disc list-inside space-y-2 text-base-content/80">
<li>
<strong>Órgãos Públicos:</strong> Quando necessário para cumprimento de obrigações
legais ou execução de políticas públicas
</li>
<li>
<strong>Fornecedores de Serviços:</strong> Empresas contratadas para prestação de
serviços técnicos, sempre com garantias de proteção de dados
</li>
<li>
<strong>Autoridades Competentes:</strong> Quando exigido por determinação judicial ou
legal
</li>
</ul>
<div class="alert alert-info mt-4">
<p class="text-sm">
Todos os compartilhamentos são realizados com base legal e com garantias de
proteção dos dados pessoais.
</p>
</div>
</div>
</section>
<!-- Segurança -->
<section class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">6. Medidas de Segurança</h2>
<p class="text-base-content/80 mb-4">
Adotamos medidas técnicas e administrativas para proteger seus dados pessoais:
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-base-200 p-4 rounded-lg">
<h3 class="font-semibold mb-2">Medidas Técnicas</h3>
<ul class="text-sm text-base-content/70 space-y-1">
<li>• Criptografia de dados sensíveis</li>
<li>• Controle de acesso por permissões</li>
<li>• Logs de auditoria</li>
<li>• Backup regular</li>
<li>• Monitoramento de segurança</li>
</ul>
</div>
<div class="bg-base-200 p-4 rounded-lg">
<h3 class="font-semibold mb-2">Medidas Administrativas</h3>
<ul class="text-sm text-base-content/70 space-y-1">
<li>• Treinamento de equipe</li>
<li>• Políticas de acesso</li>
<li>• Procedimentos de segurança</li>
<li>• Gestão de incidentes</li>
<li>• Revisão periódica</li>
</ul>
</div>
</div>
</div>
</section>
<!-- Retenção -->
<section class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">7. Prazo de Retenção</h2>
<p class="text-base-content/80 mb-4">
Os dados pessoais são mantidos pelo prazo necessário para:
</p>
<ul class="list-disc list-inside space-y-2 text-base-content/80">
<li>
<strong>Dados de Funcionários Ativos:</strong> Durante todo o período de vínculo
empregatício/estatutário
</li>
<li>
<strong>Dados de Funcionários Inativos:</strong> Conforme prazo legal aplicável (em
geral, 5 anos após desligamento)
</li>
<li>
<strong>Logs de Acesso:</strong> 2 anos, conforme recomendação da ANPD
</li>
<li>
<strong>Documentos Trabalhistas:</strong> Conforme legislação trabalhista aplicável
</li>
</ul>
<p class="text-base-content/80 mt-4">
Após o término do prazo de retenção, os dados são eliminados de forma segura, exceto
quando houver obrigação legal de manutenção.
</p>
</div>
</section>
<!-- Direitos do Titular -->
<section class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">8. Direitos do Titular dos Dados</h2>
<p class="text-base-content/80 mb-4">
Conforme previsto no Art. 18 da LGPD, você possui os seguintes direitos:
</p>
<div class="space-y-3">
<div class="flex items-start gap-3">
<div class="badge badge-primary badge-lg">1</div>
<div>
<h3 class="font-semibold">Confirmação da Existência de Tratamento</h3>
<p class="text-sm text-base-content/70">
Confirmar se tratamos seus dados pessoais
</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="badge badge-primary badge-lg">2</div>
<div>
<h3 class="font-semibold">Acesso aos Dados</h3>
<p class="text-sm text-base-content/70">
Acessar seus dados pessoais tratados por nós
</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="badge badge-primary badge-lg">3</div>
<div>
<h3 class="font-semibold">Correção de Dados</h3>
<p class="text-sm text-base-content/70">
Solicitar correção de dados incompletos, inexatos ou desatualizados
</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="badge badge-primary badge-lg">4</div>
<div>
<h3 class="font-semibold">Anonimização, Bloqueio ou Eliminação</h3>
<p class="text-sm text-base-content/70">
Solicitar anonimização, bloqueio ou eliminação de dados desnecessários ou
excessivos
</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="badge badge-primary badge-lg">5</div>
<div>
<h3 class="font-semibold">Portabilidade dos Dados</h3>
<p class="text-sm text-base-content/70">
Solicitar a portabilidade dos dados a outro fornecedor de serviço ou produto
</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="badge badge-primary badge-lg">6</div>
<div>
<h3 class="font-semibold">Eliminação de Dados</h3>
<p class="text-sm text-base-content/70">
Solicitar a eliminação dos dados pessoais tratados com base em consentimento
</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="badge badge-primary badge-lg">7</div>
<div>
<h3 class="font-semibold">Informação sobre Compartilhamento</h3>
<p class="text-sm text-base-content/70">
Obter informações sobre compartilhamento de dados com terceiros
</p>
</div>
</div>
<div class="flex items-start gap-3">
<div class="badge badge-primary badge-lg">8</div>
<div>
<h3 class="font-semibold">Revogação de Consentimento</h3>
<p class="text-sm text-base-content/70">
Revogar seu consentimento, quando aplicável
</p>
</div>
</div>
</div>
<div class="mt-6">
<a
href={resolve('/privacidade/meus-dados')}
class="btn btn-primary btn-lg w-full md:w-auto"
>
<FileText class="h-5 w-5" />
Solicitar Meus Direitos
</a>
</div>
</div>
</section>
<!-- Encarregado -->
<section class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">9. Encarregado de Proteção de Dados (DPO)</h2>
<p class="text-base-content/80 mb-4">
Para exercer seus direitos ou esclarecer dúvidas sobre o tratamento de dados pessoais,
entre em contato com nosso Encarregado de Proteção de Dados:
</p>
<div class="bg-primary/5 p-6 rounded-lg space-y-3">
<div class="flex items-center gap-3">
<Mail class="h-5 w-5 text-primary" />
<div>
<p class="text-sm font-semibold">E-mail</p>
<p class="text-base-content/70">lgpd@esportes.pe.gov.br</p>
</div>
</div>
<div class="flex items-center gap-3">
<Phone class="h-5 w-5 text-primary" />
<div>
<p class="text-sm font-semibold">Telefone</p>
<p class="text-base-content/70">(81) 3184-XXXX</p>
</div>
</div>
<div class="flex items-center gap-3">
<FileText class="h-5 w-5 text-primary" />
<div>
<p class="text-sm font-semibold">Horário de Atendimento</p>
<p class="text-base-content/70">Segunda a Sexta, das 8h às 17h</p>
</div>
</div>
</div>
<div class="alert alert-info mt-4">
<p class="text-sm">
As solicitações serão respondidas em até 15 (quinze) dias, conforme previsto na
LGPD.
</p>
</div>
</div>
</section>
<!-- Alterações -->
<section class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">10. Alterações nesta Política</h2>
<p class="text-base-content/80">
Esta Política de Privacidade pode ser atualizada periodicamente. Recomendamos que
você revise esta página regularmente para estar ciente de quaisquer alterações. A data
da última atualização está indicada no topo desta página.
</p>
</div>
</section>
<!-- Ações -->
<div class="flex flex-col sm:flex-row gap-4 mt-8">
<a href={resolve('/privacidade/meus-dados')} class="btn btn-primary btn-lg flex-1">
<FileText class="h-5 w-5" />
Solicitar Meus Direitos LGPD
</a>
<a href={resolve('/termo-consentimento')} class="btn btn-outline btn-lg flex-1">
<Shield class="h-5 w-5" />
Ver Termo de Consentimento
</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,377 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { useQuery, useMutation } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import {
Shield,
FileText,
Download,
CheckCircle,
Clock,
XCircle,
AlertCircle,
Send
} from 'lucide-svelte';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { toast } from 'svelte-sonner';
type TipoSolicitacao =
| 'acesso'
| 'correcao'
| 'exclusao'
| 'portabilidade'
| 'revogacao_consentimento'
| 'informacao_compartilhamento';
const tipoSelecionado = $state<TipoSolicitacao | null>(null);
const dadosSolicitados = $state('');
const observacoes = $state('');
const carregando = $state(false);
const minhasSolicitacoes = useQuery(api.lgpd.listarMinhasSolicitacoes, {});
const criarSolicitacao = useMutation(api.lgpd.criarSolicitacao);
const exportarDados = useQuery(api.lgpd.exportarDadosUsuario, {});
const tiposSolicitacao: Array<{ valor: TipoSolicitacao; label: string; descricao: string }> = [
{
valor: 'acesso',
label: 'Acesso aos Dados',
descricao: 'Solicitar acesso aos meus dados pessoais tratados pelo sistema'
},
{
valor: 'correcao',
label: 'Correção de Dados',
descricao: 'Solicitar correção de dados incompletos, inexatos ou desatualizados'
},
{
valor: 'exclusao',
label: 'Exclusão de Dados',
descricao: 'Solicitar exclusão de dados desnecessários ou excessivos'
},
{
valor: 'portabilidade',
label: 'Portabilidade dos Dados',
descricao: 'Solicitar exportação dos meus dados em formato portável'
},
{
valor: 'revogacao_consentimento',
label: 'Revogar Consentimento',
descricao: 'Revogar consentimento para tratamento de dados pessoais'
},
{
valor: 'informacao_compartilhamento',
label: 'Informação sobre Compartilhamento',
descricao: 'Obter informações sobre compartilhamento de dados com terceiros'
}
];
function getStatusBadge(status: string) {
switch (status) {
case 'pendente':
return { label: 'Pendente', class: 'badge-warning' };
case 'em_analise':
return { label: 'Em Análise', class: 'badge-info' };
case 'concluida':
return { label: 'Concluída', class: 'badge-success' };
case 'rejeitada':
return { label: 'Rejeitada', class: 'badge-error' };
default:
return { label: status, class: 'badge-neutral' };
}
}
function getStatusIcon(status: string) {
switch (status) {
case 'pendente':
return Clock;
case 'em_analise':
return AlertCircle;
case 'concluida':
return CheckCircle;
case 'rejeitada':
return XCircle;
default:
return FileText;
}
}
async function enviarSolicitacao() {
if (!tipoSelecionado) {
toast.error('Selecione o tipo de solicitação');
return;
}
carregando = true;
try {
await criarSolicitacao({
tipo: tipoSelecionado,
dadosSolicitados: dadosSolicitados || undefined,
observacoes: observacoes || undefined
});
toast.success('Solicitação criada com sucesso!');
tipoSelecionado = null;
dadosSolicitados = '';
observacoes = '';
} catch (error: any) {
toast.error(error.message || 'Erro ao criar solicitação');
} finally {
carregando = false;
}
}
function downloadDados() {
if (!exportarDados?.data?.dados) {
toast.error('Dados não disponíveis');
return;
}
const blob = new Blob([exportarDados.data.dados], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `meus-dados-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('Download iniciado!');
}
</script>
<div class="container mx-auto px-4 py-8 max-w-6xl">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center gap-4 mb-4">
<div class="p-3 bg-primary/10 rounded-xl">
<Shield class="h-8 w-8 text-primary" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold text-base-content">Meus Direitos LGPD</h1>
<p class="text-base-content/60 mt-1">
Solicite o exercício dos seus direitos conforme a Lei Geral de Proteção de Dados
</p>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Formulário de Solicitação -->
<div class="lg:col-span-2 space-y-6">
<!-- Nova Solicitação -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">Nova Solicitação</h2>
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-semibold">Tipo de Solicitação *</span>
</label>
<select
bind:value={tipoSelecionado}
class="select select-bordered w-full"
>
<option value={null}>Selecione o tipo de solicitação</option>
{#each tiposSolicitacao as tipo}
<option value={tipo.valor}>{tipo.label}</option>
{/each}
</select>
{#if tipoSelecionado}
<label class="label">
<span class="label-text-alt text-base-content/60">
{tiposSolicitacao.find((t) => t.valor === tipoSelecionado)?.descricao}
</span>
</label>
{/if}
</div>
{#if tipoSelecionado === 'correcao'}
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-semibold">Dados a Corrigir</span>
</label>
<textarea
bind:value={dadosSolicitados}
class="textarea textarea-bordered"
placeholder="Descreva quais dados precisam ser corrigidos e os valores corretos..."
rows="4"
></textarea>
</div>
{/if}
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-semibold">Observações</span>
</label>
<textarea
bind:value={observacoes}
class="textarea textarea-bordered"
placeholder="Informações adicionais sobre sua solicitação (opcional)..."
rows="3"
></textarea>
</div>
<button
onclick={enviarSolicitacao}
disabled={!tipoSelecionado || carregando}
class="btn btn-primary btn-lg w-full"
>
{#if carregando}
<span class="loading loading-spinner loading-sm"></span>
Enviando...
{:else}
<Send class="h-5 w-5" />
Enviar Solicitação
{/if}
</button>
<div class="alert alert-info mt-4">
<AlertCircle class="h-5 w-5" />
<p class="text-sm">
Sua solicitação será analisada e respondida em até 15 dias úteis, conforme
previsto na LGPD.
</p>
</div>
</div>
</div>
<!-- Minhas Solicitações -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">Minhas Solicitações</h2>
{#if minhasSolicitacoes === undefined}
<div class="flex justify-center items-center py-10">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if minhasSolicitacoes.length === 0}
<div class="text-center py-10">
<FileText class="h-16 w-16 text-base-content/30 mx-auto mb-4" />
<p class="text-base-content/60">Nenhuma solicitação encontrada</p>
</div>
{:else}
<div class="space-y-4">
{#each minhasSolicitacoes as solicitacao}
{@const statusInfo = getStatusBadge(solicitacao.status)}
{@const StatusIcon = getStatusIcon(solicitacao.status)}
<div class="border border-base-300 rounded-lg p-4">
<div class="flex items-start justify-between mb-2">
<div class="flex items-center gap-3">
<StatusIcon class="h-5 w-5 text-base-content/60" />
<div>
<h3 class="font-semibold">
{tiposSolicitacao.find((t) => t.valor === solicitacao.tipo)
?.label || solicitacao.tipo}
</h3>
<p class="text-sm text-base-content/60">
Criada em{' '}
{format(new Date(solicitacao.criadoEm), "dd/MM/yyyy 'às' HH:mm", {
locale: ptBR
})}
</p>
</div>
</div>
<span class="badge {statusInfo.class}">{statusInfo.label}</span>
</div>
{#if solicitacao.resposta}
<div class="mt-3 p-3 bg-base-200 rounded-lg">
<p class="text-sm font-semibold mb-1">Resposta:</p>
<p class="text-sm text-base-content/80">{solicitacao.resposta}</p>
</div>
{/if}
{#if solicitacao.arquivoResposta}
<div class="mt-3">
<a
href={solicitacao.arquivoResposta}
target="_blank"
class="btn btn-sm btn-outline"
>
<Download class="h-4 w-4" />
Baixar Arquivo
</a>
</div>
{/if}
{#if solicitacao.status === 'pendente' || solicitacao.status === 'em_analise'}
<div class="mt-3 text-xs text-base-content/60">
Prazo para resposta:{' '}
{format(new Date(solicitacao.prazoResposta), 'dd/MM/yyyy', {
locale: ptBR
})}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Exportar Dados -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="card-title text-lg mb-4">Exportar Meus Dados</h3>
<p class="text-sm text-base-content/70 mb-4">
Baixe uma cópia completa dos seus dados pessoais em formato JSON.
</p>
<button
onclick={downloadDados}
disabled={exportarDados === undefined || !exportarDados?.data?.dados}
class="btn btn-outline btn-primary w-full"
>
{#if exportarDados === undefined}
<span class="loading loading-spinner loading-sm"></span>
Carregando...
{:else}
<Download class="h-5 w-5" />
Baixar Dados
{/if}
</button>
</div>
</div>
<!-- Informações -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="card-title text-lg mb-4">Seus Direitos</h3>
<ul class="space-y-2 text-sm text-base-content/80">
<li>• Confirmar existência de tratamento</li>
<li>• Acessar seus dados</li>
<li>• Corrigir dados incorretos</li>
<li>• Solicitar exclusão</li>
<li>• Portabilidade dos dados</li>
<li>• Revogar consentimento</li>
</ul>
</div>
</div>
<!-- Links -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="card-title text-lg mb-4">Links Úteis</h3>
<div class="space-y-2">
<a href={resolve('/privacidade')} class="btn btn-sm btn-ghost w-full justify-start">
<FileText class="h-4 w-4" />
Política de Privacidade
</a>
<a
href={resolve('/perfil/privacidade')}
class="btn btn-sm btn-ghost w-full justify-start"
>
<Shield class="h-4 w-4" />
Preferências de Privacidade
</a>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,276 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { Shield, CheckCircle, AlertCircle, FileText } from 'lucide-svelte';
import { useQuery, useMutation } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
let aceito = $state(false);
let carregando = $state(false);
let erro = $state<string | null>(null);
let sucesso = $state(false);
// Verificar se já aceitou o termo
const consentimentoQuery = useQuery(api.lgpd.verificarConsentimento, { tipo: 'termo_uso' });
const registrarConsentimento = useMutation(api.lgpd.registrarConsentimento);
const jaAceitou = $derived(
consentimentoQuery?.data?.aceito === true && consentimentoQuery?.data?.versao === '1.0'
);
async function aceitarTermo() {
if (!aceito) {
erro = 'Você precisa aceitar o termo para continuar';
return;
}
carregando = true;
erro = null;
try {
await registrarConsentimento({
tipo: 'termo_uso',
aceito: true,
versao: '1.0'
});
sucesso = true;
} catch (e: any) {
erro = e.message || 'Erro ao registrar consentimento';
} finally {
carregando = false;
}
}
</script>
<div class="container mx-auto px-4 py-8 max-w-4xl">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center gap-4 mb-4">
<div class="p-3 bg-primary/10 rounded-xl">
<Shield class="h-8 w-8 text-primary" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold text-base-content">Termo de Consentimento</h1>
<p class="text-base-content/60 mt-1">
Termo de Uso e Consentimento para Tratamento de Dados Pessoais
</p>
</div>
</div>
</div>
{#if consentimentoQuery === undefined}
<div class="flex justify-center items-center py-20">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if jaAceitou}
<!-- Já aceitou -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body text-center">
<CheckCircle class="h-16 w-16 text-success mx-auto mb-4" strokeWidth={2} />
<h2 class="card-title text-2xl justify-center mb-4">Termo Já Aceito</h2>
<p class="text-base-content/80 mb-6">
Você já aceitou este termo de consentimento. Se desejar revogar seu consentimento ou
gerenciar suas preferências de privacidade, acesse a página de privacidade.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href={resolve('/privacidade')} class="btn btn-primary">
<FileText class="h-5 w-5" />
Ver Política de Privacidade
</a>
<a href={resolve('/perfil/privacidade')} class="btn btn-outline">
<Shield class="h-5 w-5" />
Gerenciar Privacidade
</a>
</div>
</div>
</div>
{:else if sucesso}
<!-- Sucesso -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body text-center">
<CheckCircle class="h-16 w-16 text-success mx-auto mb-4" strokeWidth={2} />
<h2 class="card-title text-2xl justify-center mb-4">Termo Aceito com Sucesso!</h2>
<p class="text-base-content/80 mb-6">
Seu consentimento foi registrado. Você pode acessar o sistema normalmente.
</p>
<a href={resolve('/')} class="btn btn-primary btn-lg">
Continuar para o Sistema
</a>
</div>
</div>
{:else}
<!-- Termo -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<div class="prose max-w-none">
<h2 class="text-2xl font-bold mb-4">Termo de Uso e Consentimento</h2>
<section class="mb-6">
<h3 class="text-xl font-semibold mb-3">1. Aceitação dos Termos</h3>
<p class="text-base-content/80 mb-4">
Ao utilizar o Sistema de Gestão da Secretaria de Esportes (SGSE), você concorda
com os termos e condições estabelecidos neste documento, bem como com a
Política de Privacidade do sistema.
</p>
</section>
<section class="mb-6">
<h3 class="text-xl font-semibold mb-3">2. Tratamento de Dados Pessoais</h3>
<p class="text-base-content/80 mb-4">
Você consente que a Secretaria de Esportes do Estado de Pernambuco trate seus
dados pessoais para as finalidades descritas na Política de Privacidade,
incluindo:
</p>
<ul class="list-disc list-inside space-y-2 text-base-content/80 mb-4">
<li>Gestão de recursos humanos e folha de pagamento</li>
<li>Controle de ponto e registro de jornada de trabalho</li>
<li>Gestão de férias, ausências e licenças</li>
<li>Processamento de atestados médicos e licenças de saúde</li>
<li>Comunicação interna e notificações</li>
<li>Cumprimento de obrigações legais e regulatórias</li>
</ul>
</section>
<section class="mb-6">
<h3 class="text-xl font-semibold mb-3">3. Base Legal</h3>
<p class="text-base-content/80 mb-4">
O tratamento de seus dados pessoais fundamenta-se nas seguintes bases legais,
conforme previsto na Lei Geral de Proteção de Dados (LGPD):
</p>
<ul class="list-disc list-inside space-y-2 text-base-content/80">
<li>
<strong>Execução de Políticas Públicas:</strong> Para a execução de políticas
públicas previstas em leis ou regulamentos
</li>
<li>
<strong>Cumprimento de Obrigação Legal:</strong> Para cumprimento de
obrigação legal ou regulatória
</li>
<li>
<strong>Execução de Contrato:</strong> Para a execução de contrato ou de
procedimentos preliminares relacionados a contrato
</li>
</ul>
</section>
<section class="mb-6">
<h3 class="text-xl font-semibold mb-3">4. Direitos do Titular</h3>
<p class="text-base-content/80 mb-4">
Você possui os seguintes direitos em relação aos seus dados pessoais:
</p>
<ul class="list-disc list-inside space-y-2 text-base-content/80">
<li>Confirmar a existência de tratamento de dados</li>
<li>Acessar seus dados pessoais</li>
<li>Corrigir dados incompletos, inexatos ou desatualizados</li>
<li>Solicitar anonimização, bloqueio ou eliminação de dados</li>
<li>Solicitar portabilidade dos dados</li>
<li>Revogar seu consentimento</li>
</ul>
<p class="text-base-content/80 mt-4">
Para exercer seus direitos, acesse a página{' '}
<a href={resolve('/privacidade/meus-dados')} class="link link-primary">
Solicitar Meus Direitos
</a>
.
</p>
</section>
<section class="mb-6">
<h3 class="text-xl font-semibold mb-3">5. Segurança dos Dados</h3>
<p class="text-base-content/80 mb-4">
A Secretaria de Esportes adota medidas técnicas e administrativas para proteger
seus dados pessoais contra acesso não autorizado, alteração, divulgação ou
destruição.
</p>
</section>
<section class="mb-6">
<h3 class="text-xl font-semibold mb-3">6. Revogação do Consentimento</h3>
<p class="text-base-content/80 mb-4">
Você pode revogar seu consentimento a qualquer momento através da página de
gerenciamento de privacidade. No entanto, a revogação pode impedir o acesso a
algumas funcionalidades do sistema que dependem do tratamento de dados pessoais.
</p>
</section>
<section class="mb-6">
<h3 class="text-xl font-semibold mb-3">7. Contato</h3>
<p class="text-base-content/80 mb-4">
Para questões relacionadas ao tratamento de dados pessoais, entre em contato com
o Encarregado de Proteção de Dados:
</p>
<div class="bg-base-200 p-4 rounded-lg">
<p class="text-sm">
<strong>E-mail:</strong> lgpd@esportes.pe.gov.br
</p>
<p class="text-sm">
<strong>Telefone:</strong> (81) 3184-XXXX
</p>
</div>
</section>
<div class="alert alert-warning mb-6">
<AlertCircle class="h-5 w-5" />
<p class="text-sm">
<strong>Atenção:</strong> O aceite deste termo é obrigatório para utilização do
sistema. Ao aceitar, você confirma que leu, compreendeu e concorda com todos os
termos e condições estabelecidos.
</p>
</div>
</div>
</div>
</div>
<!-- Formulário de Aceite -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-xl mb-4">Aceitar Termo</h2>
{#if erro}
<div class="alert alert-error mb-4">
<AlertCircle class="h-5 w-5" />
<span>{erro}</span>
</div>
{/if}
<div class="form-control mb-6">
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
bind:checked={aceito}
class="checkbox checkbox-primary"
/>
<span class="label-text text-base-content/80">
Declaro que li, compreendi e aceito os termos e condições estabelecidos neste
Termo de Consentimento e na{' '}
<a href={resolve('/privacidade')} class="link link-primary" target="_blank">
Política de Privacidade
</a>
.
</span>
</label>
</div>
<div class="flex flex-col sm:flex-row gap-4">
<button
onclick={aceitarTermo}
disabled={!aceito || carregando}
class="btn btn-primary btn-lg flex-1"
>
{#if carregando}
<span class="loading loading-spinner loading-sm"></span>
Processando...
{:else}
<CheckCircle class="h-5 w-5" />
Aceitar e Continuar
{/if}
</button>
<a href={resolve('/privacidade')} class="btn btn-outline btn-lg flex-1">
<FileText class="h-5 w-5" />
Ver Política de Privacidade
</a>
</div>
</div>
</div>
{/if}
</div>

View File

@@ -268,6 +268,19 @@
palette: 'success', palette: 'success',
icon: 'shieldCheck' icon: 'shieldCheck'
}, },
{
title: 'LGPD - Proteção de Dados',
description:
'Gerenciar solicitações LGPD, consentimentos, registros de tratamento e conformidade com a Lei Geral de Proteção de Dados.',
ctaLabel: 'Acessar LGPD',
href: '/(dashboard)/ti/lgpd',
palette: 'info',
icon: 'shieldCheck',
highlightBadges: [
{ label: 'Conformidade', variant: 'solid' },
{ label: 'Direitos', variant: 'outline' }
]
},
{ {
title: 'Configuração de Email', title: 'Configuração de Email',
description: description:

View File

@@ -0,0 +1,134 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { Shield, FileText, AlertTriangle, CheckCircle, Settings, Users } from 'lucide-svelte';
import StatsCard from '$lib/components/ti/StatsCard.svelte';
const estatisticas = useQuery(api.lgpd.obterEstatisticasLGPD, {});
</script>
<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">
<Shield class="h-8 w-8 text-primary" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold text-base-content">LGPD - Proteção de Dados</h1>
<p class="text-base-content/60 mt-1">Gestão de conformidade com a Lei Geral de Proteção de Dados</p>
</div>
</div>
</div>
<!-- Stats Cards -->
{#if estatisticas}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatsCard
title="Solicitações Pendentes"
value={estatisticas.solicitacoesPendentes}
description="Aguardando resposta"
Icon={AlertTriangle}
color="warning"
/>
<StatsCard
title="Solicitações Vencendo"
value={estatisticas.solicitacoesVencendo}
description="Prazo próximo"
Icon={AlertTriangle}
color="error"
/>
<StatsCard
title="Total de Solicitações"
value={estatisticas.totalSolicitacoes}
description="Todas as solicitações"
Icon={FileText}
color="info"
/>
<StatsCard
title="Consentimentos Ativos"
value={estatisticas.consentimentosAtivos}
description="Consentimentos válidos"
Icon={CheckCircle}
color="success"
/>
</div>
{:else}
<div class="flex justify-center items-center py-20">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{/if}
<!-- Ações Rápidas -->
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<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={resolve('/ti/lgpd/solicitacoes')} class="btn btn-primary btn-lg">
<FileText class="h-5 w-5" strokeWidth={2} />
Gerenciar Solicitações
</a>
<a href={resolve('/ti/lgpd/registros-tratamento')} class="btn btn-secondary btn-lg">
<FileText class="h-5 w-5" strokeWidth={2} />
Registros de Tratamento
</a>
<a href={resolve('/ti/lgpd/configuracoes')} class="btn btn-accent btn-lg">
<Settings class="h-5 w-5" strokeWidth={2} />
Configurações LGPD
</a>
</div>
</div>
</div>
<!-- Informações -->
{#if estatisticas}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Solicitações por Tipo -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-xl mb-4">Solicitações por Tipo</h2>
<div class="space-y-2">
{#each Object.entries(estatisticas.solicitacoesPorTipo) as [tipo, quantidade]}
<div class="flex justify-between items-center p-2 bg-base-200 rounded">
<span class="text-sm font-medium">{tipo}</span>
<span class="badge badge-primary">{quantidade}</span>
</div>
{/each}
</div>
</div>
</div>
<!-- Resumo -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-xl mb-4">Resumo</h2>
<div class="space-y-3">
<div class="flex justify-between">
<span>Total de ROTs:</span>
<span class="font-semibold">{estatisticas.totalROTs}</span>
</div>
<div class="flex justify-between">
<span>ROTs Ativos:</span>
<span class="font-semibold text-success">{estatisticas.rotsAtivos}</span>
</div>
<div class="flex justify-between">
<span>Total de Consentimentos:</span>
<span class="font-semibold">{estatisticas.totalConsentimentos}</span>
</div>
<div class="flex justify-between">
<span>Consentimentos Ativos:</span>
<span class="font-semibold text-success">{estatisticas.consentimentosAtivos}</span>
</div>
</div>
</div>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,196 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { useQuery, useMutation } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { Shield, Save, Mail, Phone, User, Calendar } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
const config = useQuery(api.lgpd.obterConfiguracaoLGPD, {});
const atualizarConfig = useMutation(api.lgpd.atualizarConfiguracaoLGPD);
let encarregadoNome = $state('');
let encarregadoEmail = $state('');
let encarregadoTelefone = $state('');
let prazoRespostaPadrao = $state(15);
let diasAlertaVencimento = $state(3);
let carregando = $state(false);
// Sincronizar com query
$effect(() => {
if (config?.data) {
encarregadoNome = config.data.encarregadoNome || '';
encarregadoEmail = config.data.encarregadoEmail || '';
encarregadoTelefone = config.data.encarregadoTelefone || '';
prazoRespostaPadrao = config.data.prazoRespostaPadrao;
diasAlertaVencimento = config.data.diasAlertaVencimento;
}
});
async function salvar() {
carregando = true;
try {
await atualizarConfig({
encarregadoNome: encarregadoNome || undefined,
encarregadoEmail: encarregadoEmail || undefined,
encarregadoTelefone: encarregadoTelefone || undefined,
prazoRespostaPadrao,
diasAlertaVencimento
});
toast.success('Configurações salvas com sucesso!');
} catch (error: any) {
toast.error(error.message || 'Erro ao salvar configurações');
} finally {
carregando = false;
}
}
</script>
<div class="container mx-auto px-4 py-6 max-w-4xl">
<!-- 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">
<Shield class="h-8 w-8 text-primary" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold text-base-content">Configurações LGPD</h1>
<p class="text-base-content/60 mt-1">Configure as definições de proteção de dados</p>
</div>
</div>
</div>
{#if config === undefined}
<div class="flex justify-center items-center py-20">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else}
<!-- Encarregado de Dados -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">Encarregado de Proteção de Dados (DPO)</h2>
<p class="text-base-content/60 mb-6">
Configure os dados de contato do Encarregado de Proteção de Dados, responsável por
atender solicitações e questões relacionadas à LGPD.
</p>
<div class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Nome do Encarregado</span>
</label>
<div class="relative">
<User class="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-base-content/40" />
<input
type="text"
bind:value={encarregadoNome}
placeholder="Nome completo do Encarregado"
class="input input-bordered w-full pl-10"
/>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">E-mail</span>
</label>
<div class="relative">
<Mail class="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-base-content/40" />
<input
type="email"
bind:value={encarregadoEmail}
placeholder="lgpd@esportes.pe.gov.br"
class="input input-bordered w-full pl-10"
/>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Telefone</span>
</label>
<div class="relative">
<Phone class="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-base-content/40" />
<input
type="text"
bind:value={encarregadoTelefone}
placeholder="(81) 3184-XXXX"
class="input input-bordered w-full pl-10"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Configurações de Prazos -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">Configurações de Prazos</h2>
<p class="text-base-content/60 mb-6">
Configure os prazos para resposta de solicitações e alertas de vencimento.
</p>
<div class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Prazo Padrão para Resposta (dias)</span>
</label>
<div class="relative">
<Calendar class="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-base-content/40" />
<input
type="number"
bind:value={prazoRespostaPadrao}
min="1"
max="30"
class="input input-bordered w-full pl-10"
/>
</div>
<label class="label">
<span class="label-text-alt text-base-content/60">
Prazo legal conforme LGPD: 15 dias (recomendado)
</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Dias para Alerta de Vencimento</span>
</label>
<div class="relative">
<Calendar class="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-base-content/40" />
<input
type="number"
bind:value={diasAlertaVencimento}
min="1"
max="10"
class="input input-bordered w-full pl-10"
/>
</div>
<label class="label">
<span class="label-text-alt text-base-content/60">
Dias antes do prazo para enviar alerta (padrão: 3 dias)
</span>
</label>
</div>
</div>
</div>
</div>
<!-- Botão Salvar -->
<div class="flex justify-end gap-4">
<a href={resolve('/ti/lgpd')} class="btn btn-ghost btn-lg">Cancelar</a>
<button onclick={salvar} disabled={carregando} class="btn btn-primary btn-lg">
{#if carregando}
<span class="loading loading-spinner loading-sm"></span>
Salvando...
{:else}
<Save class="h-5 w-5" />
Salvar Configurações
{/if}
</button>
</div>
{/if}
</div>

View File

@@ -0,0 +1,341 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { useQuery, useMutation } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { Shield, FileText, Plus, CheckCircle, XCircle } from 'lucide-svelte';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { toast } from 'svelte-sonner';
const registros = useQuery(api.lgpd.listarRegistrosTratamento, { ativo: undefined });
let mostrarFormulario = $state(false);
let finalidade = $state('');
let baseLegal = $state('');
let categoriasDados = $state<string[]>([]);
let categoriasTitulares = $state<string[]>([]);
let medidasSeguranca = $state<string[]>([]);
let prazoRetencao = $state(365);
let compartilhamentoTerceiros = $state(false);
let terceiros = $state<string[]>([]);
let descricao = $state('');
let carregando = $state(false);
const criarROT = useMutation(api.lgpd.criarRegistroTratamento);
const categoriasDadosDisponiveis = [
'dados_identificacao',
'dados_contato',
'dados_profissionais',
'dados_saude',
'dados_acesso'
];
const categoriasTitularesDisponiveis = [
'funcionarios',
'servidores',
'colaboradores',
'terceiros'
];
const medidasSegurancaDisponiveis = [
'criptografia',
'controle_acesso',
'logs_auditoria',
'backup',
'monitoramento'
];
function toggleArrayItem(array: string[], item: string) {
if (array.includes(item)) {
return array.filter((i) => i !== item);
} else {
return [...array, item];
}
}
async function salvar() {
if (!finalidade.trim() || !baseLegal.trim()) {
toast.error('Preencha todos os campos obrigatórios');
return;
}
carregando = true;
try {
await criarROT({
finalidade: finalidade.trim(),
baseLegal: baseLegal.trim(),
categoriasDados,
categoriasTitulares,
medidasSeguranca,
prazoRetencao,
compartilhamentoTerceiros,
terceiros: compartilhamentoTerceiros && terceiros.length > 0 ? terceiros : undefined,
descricao: descricao.trim() || undefined
});
toast.success('Registro de Tratamento criado com sucesso!');
mostrarFormulario = false;
// Reset form
finalidade = '';
baseLegal = '';
categoriasDados = [];
categoriasTitulares = [];
medidasSeguranca = [];
prazoRetencao = 365;
compartilhamentoTerceiros = false;
terceiros = [];
descricao = '';
} catch (error: any) {
toast.error(error.message || 'Erro ao criar registro');
} finally {
carregando = false;
}
}
</script>
<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">
<Shield class="h-8 w-8 text-primary" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold text-base-content">Registros de Tratamento (ROT)</h1>
<p class="text-base-content/60 mt-1">
Gerencie os registros de operações de tratamento de dados pessoais
</p>
</div>
</div>
<button onclick={() => (mostrarFormulario = !mostrarFormulario)} class="btn btn-primary">
<Plus class="h-5 w-5" />
Novo ROT
</button>
</div>
<!-- Formulário -->
{#if mostrarFormulario}
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">Criar Novo Registro de Tratamento</h2>
<div class="space-y-4">
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Finalidade *</span>
</label>
<input
type="text"
bind:value={finalidade}
placeholder="Ex: Gestão de recursos humanos e folha de pagamento"
class="input input-bordered"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Base Legal *</span>
</label>
<input
type="text"
bind:value={baseLegal}
placeholder="Ex: Art. , II - Execução de políticas públicas"
class="input input-bordered"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Categorias de Dados</span>
</label>
<div class="flex flex-wrap gap-2">
{#each categoriasDadosDisponiveis as categoria}
<label class="cursor-pointer label gap-2">
<input
type="checkbox"
checked={categoriasDados.includes(categoria)}
onchange={() => (categoriasDados = toggleArrayItem(categoriasDados, categoria))}
class="checkbox checkbox-primary checkbox-sm"
/>
<span class="label-text text-sm">{categoria}</span>
</label>
{/each}
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Categorias de Titulares</span>
</label>
<div class="flex flex-wrap gap-2">
{#each categoriasTitularesDisponiveis as categoria}
<label class="cursor-pointer label gap-2">
<input
type="checkbox"
checked={categoriasTitulares.includes(categoria)}
onchange={() =>
(categoriasTitulares = toggleArrayItem(categoriasTitulares, categoria))}
class="checkbox checkbox-primary checkbox-sm"
/>
<span class="label-text text-sm">{categoria}</span>
</label>
{/each}
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Medidas de Segurança</span>
</label>
<div class="flex flex-wrap gap-2">
{#each medidasSegurancaDisponiveis as medida}
<label class="cursor-pointer label gap-2">
<input
type="checkbox"
checked={medidasSeguranca.includes(medida)}
onchange={() =>
(medidasSeguranca = toggleArrayItem(medidasSeguranca, medida))}
class="checkbox checkbox-primary checkbox-sm"
/>
<span class="label-text text-sm">{medida}</span>
</label>
{/each}
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Prazo de Retenção (dias)</span>
</label>
<input
type="number"
bind:value={prazoRetencao}
min="1"
class="input input-bordered"
/>
</div>
<div class="form-control">
<label class="cursor-pointer label justify-start gap-4">
<input
type="checkbox"
bind:checked={compartilhamentoTerceiros}
class="checkbox checkbox-primary"
/>
<span class="label-text font-semibold">Compartilhamento com Terceiros</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Descrição</span>
</label>
<textarea
bind:value={descricao}
class="textarea textarea-bordered"
rows="4"
placeholder="Descrição detalhada do tratamento..."
></textarea>
</div>
<div class="flex justify-end gap-4">
<button
onclick={() => (mostrarFormulario = false)}
class="btn btn-ghost"
>
Cancelar
</button>
<button onclick={salvar} disabled={carregando} class="btn btn-primary">
{#if carregando}
<span class="loading loading-spinner loading-sm"></span>
Salvando...
{:else}
<Plus class="h-5 w-5" />
Criar ROT
{/if}
</button>
</div>
</div>
</div>
</div>
{/if}
<!-- Lista de ROTs -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">Registros de Tratamento</h2>
{#if registros === undefined}
<div class="flex justify-center items-center py-20">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if registros.length === 0}
<div class="text-center py-10">
<FileText class="h-16 w-16 text-base-content/30 mx-auto mb-4" />
<p class="text-base-content/60">Nenhum registro de tratamento encontrado</p>
</div>
{:else}
<div class="space-y-4">
{#each registros as registro}
<div class="border border-base-300 rounded-lg p-4">
<div class="flex items-start justify-between mb-3">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="font-semibold text-lg">{registro.finalidade}</h3>
{#if registro.ativo}
<span class="badge badge-success">Ativo</span>
{:else}
<span class="badge badge-error">Inativo</span>
{/if}
</div>
<div class="space-y-2 text-sm text-base-content/70">
<div>
<span class="font-semibold">Base Legal:</span> {registro.baseLegal}
</div>
<div>
<span class="font-semibold">Categorias de Dados:</span>{' '}
{registro.categoriasDados.join(', ')}
</div>
<div>
<span class="font-semibold">Categorias de Titulares:</span>{' '}
{registro.categoriasTitulares.join(', ')}
</div>
<div>
<span class="font-semibold">Medidas de Segurança:</span>{' '}
{registro.medidasSeguranca.join(', ')}
</div>
<div>
<span class="font-semibold">Prazo de Retenção:</span>{' '}
{registro.prazoRetencao} dias
</div>
<div>
<span class="font-semibold">Compartilhamento:</span>{' '}
{registro.compartilhamentoTerceiros ? 'Sim' : 'Não'}
</div>
{#if registro.terceiros && registro.terceiros.length > 0}
<div>
<span class="font-semibold">Terceiros:</span>{' '}
{registro.terceiros.join(', ')}
</div>
{/if}
<div>
<span class="font-semibold">Responsável:</span> {registro.responsavelNome}
</div>
<div>
<span class="font-semibold">Criado em:</span>{' '}
{format(new Date(registro.criadoEm), 'dd/MM/yyyy', { locale: ptBR })}
</div>
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,333 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { useQuery, useMutation } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import {
Shield,
FileText,
CheckCircle,
Clock,
XCircle,
AlertCircle,
Search,
Filter
} from 'lucide-svelte';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { toast } from 'svelte-sonner';
type StatusFiltro = 'pendente' | 'em_analise' | 'concluida' | 'rejeitada' | null;
type TipoFiltro =
| 'acesso'
| 'correcao'
| 'exclusao'
| 'portabilidade'
| 'revogacao_consentimento'
| 'informacao_compartilhamento'
| null;
let statusFiltro = $state<StatusFiltro>(null);
let tipoFiltro = $state<TipoFiltro>(null);
let termoBusca = $state('');
const solicitacoes = useQuery(api.lgpd.listarSolicitacoes, {
status: statusFiltro || undefined,
tipo: tipoFiltro || undefined
});
const responderSolicitacao = useMutation(api.lgpd.responderSolicitacao);
let solicitacaoSelecionada = $state<string | null>(null);
let resposta = $state('');
let statusResposta = $state<'concluida' | 'rejeitada' | 'em_analise'>('concluida');
let carregando = $state(false);
function getStatusBadge(status: string) {
switch (status) {
case 'pendente':
return { label: 'Pendente', class: 'badge-warning' };
case 'em_analise':
return { label: 'Em Análise', class: 'badge-info' };
case 'concluida':
return { label: 'Concluída', class: 'badge-success' };
case 'rejeitada':
return { label: 'Rejeitada', class: 'badge-error' };
default:
return { label: status, class: 'badge-neutral' };
}
}
function getTipoLabel(tipo: string) {
const labels: Record<string, string> = {
acesso: 'Acesso aos Dados',
correcao: 'Correção de Dados',
exclusao: 'Exclusão de Dados',
portabilidade: 'Portabilidade dos Dados',
revogacao_consentimento: 'Revogar Consentimento',
informacao_compartilhamento: 'Informação sobre Compartilhamento'
};
return labels[tipo] || tipo;
}
function getStatusIcon(status: string) {
switch (status) {
case 'pendente':
return Clock;
case 'em_analise':
return AlertCircle;
case 'concluida':
return CheckCircle;
case 'rejeitada':
return XCircle;
default:
return FileText;
}
}
function filtrarSolicitacoes() {
if (!solicitacoes) return [];
if (!termoBusca) return solicitacoes;
const busca = termoBusca.toLowerCase();
return solicitacoes.filter(
(s) =>
s.usuarioNome.toLowerCase().includes(busca) ||
s.usuarioEmail.toLowerCase().includes(busca) ||
(s.usuarioMatricula?.toLowerCase().includes(busca) ?? false)
);
}
async function responder() {
if (!solicitacaoSelecionada || !resposta.trim()) {
toast.error('Preencha a resposta');
return;
}
carregando = true;
try {
await responderSolicitacao({
solicitacaoId: solicitacaoSelecionada as any,
resposta: resposta.trim(),
status: statusResposta
});
toast.success('Solicitação respondida com sucesso!');
solicitacaoSelecionada = null;
resposta = '';
} catch (error: any) {
toast.error(error.message || 'Erro ao responder solicitação');
} finally {
carregando = false;
}
}
const solicitacoesFiltradas = $derived(filtrarSolicitacoes());
</script>
<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">
<Shield class="h-8 w-8 text-primary" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold text-base-content">Gestão de Solicitações LGPD</h1>
<p class="text-base-content/60 mt-1">Responda e gerencie solicitações de direitos dos titulares</p>
</div>
</div>
</div>
<!-- Filtros -->
<div class="card bg-base-100 shadow-xl mb-6">
<div class="card-body">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Status</span>
</label>
<select bind:value={statusFiltro} class="select select-bordered">
<option value={null}>Todos</option>
<option value="pendente">Pendente</option>
<option value="em_analise">Em Análise</option>
<option value="concluida">Concluída</option>
<option value="rejeitada">Rejeitada</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Tipo</span>
</label>
<select bind:value={tipoFiltro} class="select select-bordered">
<option value={null}>Todos</option>
<option value="acesso">Acesso</option>
<option value="correcao">Correção</option>
<option value="exclusao">Exclusão</option>
<option value="portabilidade">Portabilidade</option>
<option value="revogacao_consentimento">Revogar Consentimento</option>
<option value="informacao_compartilhamento">Informação Compartilhamento</option>
</select>
</div>
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text font-semibold">Buscar</span>
</label>
<div class="relative">
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-base-content/40" />
<input
type="text"
bind:value={termoBusca}
placeholder="Buscar por nome, email ou matrícula..."
class="input input-bordered w-full pl-10"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Lista de Solicitações -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">Solicitações</h2>
{#if solicitacoes === undefined}
<div class="flex justify-center items-center py-20">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if solicitacoesFiltradas.length === 0}
<div class="text-center py-10">
<FileText class="h-16 w-16 text-base-content/30 mx-auto mb-4" />
<p class="text-base-content/60">Nenhuma solicitação encontrada</p>
</div>
{:else}
<div class="space-y-4">
{#each solicitacoesFiltradas as solicitacao}
{@const statusInfo = getStatusBadge(solicitacao.status)}
{@const StatusIcon = getStatusIcon(solicitacao.status)}
<div class="border border-base-300 rounded-lg p-4">
<div class="flex items-start justify-between mb-3">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<StatusIcon class="h-5 w-5 text-base-content/60" />
<h3 class="font-semibold text-lg">
{getTipoLabel(solicitacao.tipo)}
</h3>
<span class="badge {statusInfo.class}">{statusInfo.label}</span>
</div>
<div class="space-y-1 text-sm text-base-content/70">
<div>
<span class="font-semibold">Solicitante:</span> {solicitacao.usuarioNome}
{#if solicitacao.usuarioMatricula}
({solicitacao.usuarioMatricula})
{/if}
</div>
<div>
<span class="font-semibold">E-mail:</span> {solicitacao.usuarioEmail}
</div>
<div>
<span class="font-semibold">Criada em:</span>{' '}
{format(new Date(solicitacao.criadoEm), "dd/MM/yyyy 'às' HH:mm", {
locale: ptBR
})}
</div>
{#if solicitacao.respondidoEm}
<div>
<span class="font-semibold">Respondida em:</span>{' '}
{format(
new Date(solicitacao.respondidoEm),
"dd/MM/yyyy 'às' HH:mm",
{ locale: ptBR }
)}
</div>
{#if solicitacao.respondidoPorNome}
<div>
<span class="font-semibold">Respondida por:</span>{' '}
{solicitacao.respondidoPorNome}
</div>
{/if}
{/if}
{#if solicitacao.status === 'pendente' || solicitacao.status === 'em_analise'}
<div class="text-warning">
<span class="font-semibold">Prazo:</span>{' '}
{format(new Date(solicitacao.prazoResposta), 'dd/MM/yyyy', {
locale: ptBR
})}
</div>
{/if}
</div>
</div>
{#if solicitacao.status === 'pendente' || solicitacao.status === 'em_analise'}
<button
onclick={() => (solicitacaoSelecionada = solicitacao._id)}
class="btn btn-primary btn-sm"
>
Responder
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- Modal de Resposta -->
{#if solicitacaoSelecionada}
<div class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">Responder Solicitação</h3>
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-semibold">Status</span>
</label>
<select bind:value={statusResposta} class="select select-bordered">
<option value="concluida">Concluída</option>
<option value="rejeitada">Rejeitada</option>
<option value="em_analise">Em Análise</option>
</select>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-semibold">Resposta *</span>
</label>
<textarea
bind:value={resposta}
class="textarea textarea-bordered"
placeholder="Digite a resposta para o solicitante..."
rows="6"
></textarea>
</div>
<div class="modal-action">
<button
onclick={() => {
solicitacaoSelecionada = null;
resposta = '';
}}
class="btn btn-ghost"
>
Cancelar
</button>
<button onclick={responder} disabled={!resposta.trim() || carregando} class="btn btn-primary">
{#if carregando}
<span class="loading loading-spinner loading-sm"></span>
Enviando...
{:else}
Enviar Resposta
{/if}
</button>
</div>
</div>
<div class="modal-backdrop" onclick={() => (solicitacaoSelecionada = null)}></div>
</div>
{/if}
</div>

View File

@@ -2,11 +2,12 @@
import { useQuery, useConvexClient } from "convex-svelte"; import { useQuery, useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api"; import { api } from "@sgse-app/backend/convex/_generated/api";
import StatsCard from "$lib/components/ti/StatsCard.svelte"; import StatsCard from "$lib/components/ti/StatsCard.svelte";
import { BarChart3, Users, CheckCircle2, Ban, Clock, Plus, Layers, FileText, Info } from "lucide-svelte"; import { BarChart3, Users, CheckCircle2, Ban, Clock, Plus, Layers, FileText, Info, Shield, AlertTriangle } from "lucide-svelte";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
const client = useConvexClient(); const client = useConvexClient();
const usuariosQuery = useQuery(api.usuarios.listar, {}); const usuariosQuery = useQuery(api.usuarios.listar, {});
const estatisticasLGPD = useQuery(api.lgpd.obterEstatisticasLGPD, {});
// Verificar se está carregando // Verificar se está carregando
const carregando = $derived(usuariosQuery === undefined); const carregando = $derived(usuariosQuery === undefined);
@@ -96,6 +97,54 @@
</div> </div>
{/if} {/if}
<!-- LGPD Stats Cards -->
{#if estatisticasLGPD}
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h2 class="card-title text-2xl">LGPD - Proteção de Dados</h2>
<a href={resolve("/ti/lgpd")} class="btn btn-sm btn-primary">
<Shield class="h-4 w-4" />
Acessar LGPD
</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatsCard
title="Solicitações Pendentes"
value={estatisticasLGPD.solicitacoesPendentes}
description="Aguardando resposta"
Icon={AlertTriangle}
color="warning"
/>
<StatsCard
title="Solicitações Vencendo"
value={estatisticasLGPD.solicitacoesVencendo}
description="Prazo próximo"
Icon={AlertTriangle}
color="error"
/>
<StatsCard
title="Total de Solicitações"
value={estatisticasLGPD.totalSolicitacoes}
description="Todas as solicitações"
Icon={FileText}
color="info"
/>
<StatsCard
title="Consentimentos Ativos"
value={estatisticasLGPD.consentimentosAtivos}
description="Consentimentos válidos"
Icon={CheckCircle2}
color="success"
/>
</div>
</div>
</div>
{/if}
<!-- Ações Rápidas --> <!-- Ações Rápidas -->
<div class="card bg-base-100 shadow-xl mb-8"> <div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body"> <div class="card-body">
@@ -115,6 +164,11 @@
<FileText class="h-5 w-5" strokeWidth={2} /> <FileText class="h-5 w-5" strokeWidth={2} />
Ver Logs Ver Logs
</a> </a>
<a href={resolve("/ti/lgpd")} class="btn btn-info">
<Shield class="h-5 w-5" strokeWidth={2} />
LGPD
</a>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -39,6 +39,7 @@ import type * as funcionarioEnderecos from "../funcionarioEnderecos.js";
import type * as funcionarios from "../funcionarios.js"; import type * as funcionarios from "../funcionarios.js";
import type * as healthCheck from "../healthCheck.js"; import type * as healthCheck from "../healthCheck.js";
import type * as http from "../http.js"; import type * as http from "../http.js";
import type * as lgpd from "../lgpd.js";
import type * as logsAcesso from "../logsAcesso.js"; import type * as logsAcesso from "../logsAcesso.js";
import type * as logsAtividades from "../logsAtividades.js"; import type * as logsAtividades from "../logsAtividades.js";
import type * as logsLogin from "../logsLogin.js"; import type * as logsLogin from "../logsLogin.js";
@@ -101,6 +102,7 @@ declare const fullApi: ApiFromModules<{
funcionarios: typeof funcionarios; funcionarios: typeof funcionarios;
healthCheck: typeof healthCheck; healthCheck: typeof healthCheck;
http: typeof http; http: typeof http;
lgpd: typeof lgpd;
logsAcesso: typeof logsAcesso; logsAcesso: typeof logsAcesso;
logsAtividades: typeof logsAtividades; logsAtividades: typeof logsAtividades;
logsLogin: typeof logsLogin; logsLogin: typeof logsLogin;

View File

@@ -0,0 +1,796 @@
import { v } from 'convex/values';
import { mutation, query } from './_generated/server';
import { getCurrentUserFunction } from './auth';
import { Id, Doc } from './_generated/dataModel';
import type { QueryCtx, MutationCtx } from './_generated/server';
import { registrarAtividade } from './logsAtividades';
/**
* Verificar se usuário aceitou o termo de consentimento
*/
export const verificarConsentimento = query({
args: {
tipo: v.optional(
v.union(
v.literal('termo_uso'),
v.literal('politica_privacidade'),
v.literal('comunicacoes'),
v.literal('compartilhamento_dados')
)
)
},
returns: v.union(
v.object({
aceito: v.boolean(),
versao: v.string(),
aceitoEm: v.number()
}),
v.null()
),
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
return null;
}
const tipo = args.tipo || 'termo_uso';
const consentimento = await ctx.db
.query('consentimentos')
.withIndex('by_usuario_tipo', (q) => q.eq('usuarioId', usuario._id).eq('tipo', tipo))
.order('desc')
.first();
if (!consentimento || !consentimento.aceito || consentimento.revogadoEm) {
return null;
}
return {
aceito: consentimento.aceito,
versao: consentimento.versao,
aceitoEm: consentimento.aceitoEm
};
}
});
/**
* Registrar consentimento do usuário
*/
export const registrarConsentimento = mutation({
args: {
tipo: v.union(
v.literal('termo_uso'),
v.literal('politica_privacidade'),
v.literal('comunicacoes'),
v.literal('compartilhamento_dados')
),
aceito: v.boolean(),
versao: v.string(),
ipAddress: v.optional(v.string()),
userAgent: v.optional(v.string())
},
returns: v.object({ sucesso: v.boolean(), consentimentoId: v.id('consentimentos') }),
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
// Verificar se já existe consentimento ativo
const existente = await ctx.db
.query('consentimentos')
.withIndex('by_usuario_tipo', (q) => q.eq('usuarioId', usuario._id).eq('tipo', args.tipo))
.order('desc')
.first();
if (existente && existente.aceito && !existente.revogadoEm) {
// Atualizar consentimento existente
await ctx.db.patch(existente._id, {
aceito: args.aceito,
versao: args.versao,
aceitoEm: Date.now(),
ipAddress: args.ipAddress,
userAgent: args.userAgent,
revogadoEm: undefined,
revogadoPor: undefined
});
return { sucesso: true, consentimentoId: existente._id };
}
// Criar novo consentimento
const consentimentoId = await ctx.db.insert('consentimentos', {
usuarioId: usuario._id,
tipo: args.tipo,
aceito: args.aceito,
versao: args.versao,
ipAddress: args.ipAddress,
userAgent: args.userAgent,
aceitoEm: Date.now()
});
// Log de atividade
await registrarAtividade(
ctx,
usuario._id,
'aceitar_consentimento',
'consentimentos',
JSON.stringify({ tipo: args.tipo, versao: args.versao }),
consentimentoId.toString()
);
return { sucesso: true, consentimentoId };
}
});
/**
* Revogar consentimento
*/
export const revogarConsentimento = mutation({
args: {
tipo: v.union(
v.literal('termo_uso'),
v.literal('politica_privacidade'),
v.literal('comunicacoes'),
v.literal('compartilhamento_dados')
)
},
returns: v.object({ sucesso: v.boolean() }),
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
const consentimento = await ctx.db
.query('consentimentos')
.withIndex('by_usuario_tipo', (q) => q.eq('usuarioId', usuario._id).eq('tipo', args.tipo))
.order('desc')
.first();
if (!consentimento) {
throw new Error('Consentimento não encontrado');
}
await ctx.db.patch(consentimento._id, {
revogadoEm: Date.now(),
revogadoPor: usuario._id
});
// Log de atividade
await registrarAtividade(
ctx,
usuario._id,
'revogar_consentimento',
'consentimentos',
JSON.stringify({ tipo: args.tipo }),
consentimento._id.toString()
);
return { sucesso: true };
}
});
/**
* Listar consentimentos do usuário
*/
export const listarConsentimentos = query({
args: {},
returns: v.array(
v.object({
_id: v.id('consentimentos'),
tipo: v.string(),
aceito: v.boolean(),
versao: v.string(),
aceitoEm: v.number(),
revogadoEm: v.union(v.number(), v.null())
})
),
handler: async (ctx) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
return [];
}
const consentimentos = await ctx.db
.query('consentimentos')
.withIndex('by_usuario', (q) => q.eq('usuarioId', usuario._id))
.order('desc')
.collect();
return consentimentos.map((c) => ({
_id: c._id,
tipo: c.tipo,
aceito: c.aceito,
versao: c.versao,
aceitoEm: c.aceitoEm,
revogadoEm: c.revogadoEm ?? null
}));
}
});
/**
* Criar solicitação de direito LGPD
*/
export const criarSolicitacao = mutation({
args: {
tipo: v.union(
v.literal('acesso'),
v.literal('correcao'),
v.literal('exclusao'),
v.literal('portabilidade'),
v.literal('revogacao_consentimento'),
v.literal('informacao_compartilhamento')
),
dadosSolicitados: v.optional(v.string()),
observacoes: v.optional(v.string())
},
returns: v.object({ sucesso: v.boolean(), solicitacaoId: v.id('solicitacoesLGPD') }),
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
// Prazo de resposta: 15 dias (conforme LGPD)
const prazoResposta = Date.now() + 15 * 24 * 60 * 60 * 1000;
const solicitacaoId = await ctx.db.insert('solicitacoesLGPD', {
tipo: args.tipo,
usuarioId: usuario._id,
funcionarioId: usuario.funcionarioId,
status: 'pendente',
dadosSolicitados: args.dadosSolicitados,
observacoes: args.observacoes,
criadoEm: Date.now(),
prazoResposta
});
// Log de atividade
await registrarAtividade(
ctx,
usuario._id,
'criar_solicitacao_lgpd',
'solicitacoesLGPD',
JSON.stringify({ tipo: args.tipo }),
solicitacaoId.toString()
);
return { sucesso: true, solicitacaoId };
}
});
/**
* Listar solicitações do usuário
*/
export const listarMinhasSolicitacoes = query({
args: {
status: v.optional(
v.union(
v.literal('pendente'),
v.literal('em_analise'),
v.literal('concluida'),
v.literal('rejeitada')
)
)
},
returns: v.array(
v.object({
_id: v.id('solicitacoesLGPD'),
tipo: v.string(),
status: v.string(),
criadoEm: v.number(),
prazoResposta: v.number(),
respondidoEm: v.union(v.number(), v.null()),
resposta: v.union(v.string(), v.null()),
arquivoResposta: v.union(v.string(), v.null())
})
),
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
return [];
}
let solicitacoes = await ctx.db
.query('solicitacoesLGPD')
.withIndex('by_usuario', (q) => q.eq('usuarioId', usuario._id))
.order('desc')
.collect();
if (args.status) {
solicitacoes = solicitacoes.filter((s) => s.status === args.status);
}
return solicitacoes.map((s) => ({
_id: s._id,
tipo: s.tipo,
status: s.status,
criadoEm: s.criadoEm,
prazoResposta: s.prazoResposta,
respondidoEm: s.respondidoEm ?? null,
resposta: s.resposta ?? null,
arquivoResposta: s.arquivoResposta ? s.arquivoResposta.toString() : null
}));
}
});
/**
* Listar todas as solicitações (apenas TI)
*/
export const listarSolicitacoes = query({
args: {
status: v.optional(
v.union(
v.literal('pendente'),
v.literal('em_analise'),
v.literal('concluida'),
v.literal('rejeitada')
)
),
tipo: v.optional(
v.union(
v.literal('acesso'),
v.literal('correcao'),
v.literal('exclusao'),
v.literal('portabilidade'),
v.literal('revogacao_consentimento'),
v.literal('informacao_compartilhamento')
)
),
limite: v.optional(v.number())
},
returns: v.array(
v.object({
_id: v.id('solicitacoesLGPD'),
tipo: v.string(),
status: v.string(),
usuarioNome: v.string(),
usuarioEmail: v.string(),
usuarioMatricula: v.union(v.string(), v.null()),
criadoEm: v.number(),
prazoResposta: v.number(),
respondidoEm: v.union(v.number(), v.null()),
respondidoPorNome: v.union(v.string(), v.null())
})
),
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
return [];
}
// Verificar se é TI (simplificado - pode melhorar com verificação de role)
// Por enquanto, qualquer usuário autenticado pode ver (será melhorado)
let solicitacoes = await ctx.db.query('solicitacoesLGPD').order('desc').collect();
if (args.status) {
solicitacoes = solicitacoes.filter((s) => s.status === args.status);
}
if (args.tipo) {
solicitacoes = solicitacoes.filter((s) => s.tipo === args.tipo);
}
if (args.limite) {
solicitacoes = solicitacoes.slice(0, args.limite);
}
// Enriquecer com dados do usuário
const resultado = await Promise.all(
solicitacoes.map(async (s) => {
const usuarioSolicitante = await ctx.db.get(s.usuarioId);
let matricula: string | null = null;
if (usuarioSolicitante?.funcionarioId) {
const funcionario = await ctx.db.get(usuarioSolicitante.funcionarioId);
matricula = funcionario?.matricula ?? null;
}
let respondidoPorNome: string | null = null;
if (s.respondidoPor) {
const respondente = await ctx.db.get(s.respondidoPor);
respondidoPorNome = respondente?.nome ?? null;
}
return {
_id: s._id,
tipo: s.tipo,
status: s.status,
usuarioNome: usuarioSolicitante?.nome ?? 'Usuário Desconhecido',
usuarioEmail: usuarioSolicitante?.email ?? '',
usuarioMatricula: matricula,
criadoEm: s.criadoEm,
prazoResposta: s.prazoResposta,
respondidoEm: s.respondidoEm ?? null,
respondidoPorNome
};
})
);
return resultado;
}
});
/**
* Responder solicitação (apenas TI)
*/
export const responderSolicitacao = mutation({
args: {
solicitacaoId: v.id('solicitacoesLGPD'),
resposta: v.string(),
status: v.union(
v.literal('concluida'),
v.literal('rejeitada'),
v.literal('em_analise')
),
arquivoResposta: v.optional(v.id('_storage'))
},
returns: v.object({ sucesso: v.boolean() }),
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
const solicitacao = await ctx.db.get(args.solicitacaoId);
if (!solicitacao) {
throw new Error('Solicitação não encontrada');
}
await ctx.db.patch(args.solicitacaoId, {
status: args.status,
resposta: args.resposta,
arquivoResposta: args.arquivoResposta,
respondidoPor: usuario._id,
respondidoEm: Date.now()
});
// Log de atividade
await registrarAtividade(
ctx,
usuario._id,
'responder_solicitacao_lgpd',
'solicitacoesLGPD',
JSON.stringify({ solicitacaoId: args.solicitacaoId, status: args.status }),
args.solicitacaoId.toString()
);
return { sucesso: true };
}
});
/**
* Exportar dados do usuário (portabilidade)
*/
export const exportarDadosUsuario = query({
args: {},
returns: v.object({
dados: v.string() // JSON string com todos os dados do usuário
}),
handler: async (ctx) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
// Buscar todos os dados do usuário
const dadosUsuario: any = {
usuario: {
nome: usuario.nome,
email: usuario.email,
setor: usuario.setor
},
consentimentos: [],
solicitacoes: [],
atividades: []
};
// Consentimentos
const consentimentos = await ctx.db
.query('consentimentos')
.withIndex('by_usuario', (q) => q.eq('usuarioId', usuario._id))
.collect();
dadosUsuario.consentimentos = consentimentos.map((c) => ({
tipo: c.tipo,
aceito: c.aceito,
versao: c.versao,
aceitoEm: c.aceitoEm,
revogadoEm: c.revogadoEm
}));
// Solicitações LGPD
const solicitacoes = await ctx.db
.query('solicitacoesLGPD')
.withIndex('by_usuario', (q) => q.eq('usuarioId', usuario._id))
.collect();
dadosUsuario.solicitacoes = solicitacoes.map((s) => ({
tipo: s.tipo,
status: s.status,
criadoEm: s.criadoEm,
respondidoEm: s.respondidoEm
}));
// Dados do funcionário (se houver)
if (usuario.funcionarioId) {
const funcionario = await ctx.db.get(usuario.funcionarioId);
if (funcionario) {
dadosUsuario.funcionario = {
nome: funcionario.nome,
matricula: funcionario.matricula,
cpf: funcionario.cpf,
email: funcionario.email,
telefone: funcionario.telefone,
cargo: funcionario.cargo,
setor: funcionario.setor
};
}
}
return {
dados: JSON.stringify(dadosUsuario, null, 2)
};
}
});
/**
* Criar Registro de Operação de Tratamento (ROT)
*/
export const criarRegistroTratamento = mutation({
args: {
finalidade: v.string(),
baseLegal: v.string(),
categoriasDados: v.array(v.string()),
categoriasTitulares: v.array(v.string()),
medidasSeguranca: v.array(v.string()),
prazoRetencao: v.number(),
compartilhamentoTerceiros: v.boolean(),
terceiros: v.optional(v.array(v.string())),
descricao: v.optional(v.string())
},
returns: v.object({ sucesso: v.boolean(), registroId: v.id('registrosTratamento') }),
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
const agora = Date.now();
const registroId = await ctx.db.insert('registrosTratamento', {
finalidade: args.finalidade,
baseLegal: args.baseLegal,
categoriasDados: args.categoriasDados,
categoriasTitulares: args.categoriasTitulares,
medidasSeguranca: args.medidasSeguranca,
prazoRetencao: args.prazoRetencao,
compartilhamentoTerceiros: args.compartilhamentoTerceiros,
terceiros: args.terceiros,
responsavel: usuario._id,
descricao: args.descricao,
criadoEm: agora,
atualizadoEm: agora,
ativo: true
});
// Log de atividade
await registrarAtividade(
ctx,
usuario._id,
'criar_rot',
'registrosTratamento',
JSON.stringify({ finalidade: args.finalidade }),
registroId.toString()
);
return { sucesso: true, registroId };
}
});
/**
* Listar Registros de Tratamento
*/
export const listarRegistrosTratamento = query({
args: {
ativo: v.optional(v.boolean())
},
returns: v.array(
v.object({
_id: v.id('registrosTratamento'),
finalidade: v.string(),
baseLegal: v.string(),
categoriasDados: v.array(v.string()),
categoriasTitulares: v.array(v.string()),
medidasSeguranca: v.array(v.string()),
prazoRetencao: v.number(),
compartilhamentoTerceiros: v.boolean(),
terceiros: v.union(v.array(v.string()), v.null()),
responsavelNome: v.string(),
criadoEm: v.number(),
atualizadoEm: v.number(),
ativo: v.boolean()
})
),
handler: async (ctx, args) => {
let registros = await ctx.db.query('registrosTratamento').collect();
if (args.ativo !== undefined) {
registros = registros.filter((r) => r.ativo === args.ativo);
}
// Enriquecer com nome do responsável
const resultado = await Promise.all(
registros.map(async (r) => {
const responsavel = await ctx.db.get(r.responsavel);
return {
_id: r._id,
finalidade: r.finalidade,
baseLegal: r.baseLegal,
categoriasDados: r.categoriasDados,
categoriasTitulares: r.categoriasTitulares,
medidasSeguranca: r.medidasSeguranca,
prazoRetencao: r.prazoRetencao,
compartilhamentoTerceiros: r.compartilhamentoTerceiros,
terceiros: r.terceiros ?? null,
responsavelNome: responsavel?.nome ?? 'Desconhecido',
criadoEm: r.criadoEm,
atualizadoEm: r.atualizadoEm,
ativo: r.ativo
};
})
);
return resultado;
}
});
/**
* Obter configurações LGPD
*/
export const obterConfiguracaoLGPD = query({
args: {},
returns: v.union(
v.object({
encarregadoNome: v.union(v.string(), v.null()),
encarregadoEmail: v.union(v.string(), v.null()),
encarregadoTelefone: v.union(v.string(), v.null()),
prazoRespostaPadrao: v.number(),
diasAlertaVencimento: v.number()
}),
v.null()
),
handler: async (ctx) => {
const config = await ctx.db
.query('configuracaoLGPD')
.withIndex('by_ativo', (q) => q.eq('ativo', true))
.first();
if (!config) {
// Retornar valores padrão
return {
encarregadoNome: null,
encarregadoEmail: null,
encarregadoTelefone: null,
prazoRespostaPadrao: 15,
diasAlertaVencimento: 3
};
}
return {
encarregadoNome: config.encarregadoNome ?? null,
encarregadoEmail: config.encarregadoEmail ?? null,
encarregadoTelefone: config.encarregadoTelefone ?? null,
prazoRespostaPadrao: config.prazoRespostaPadrao,
diasAlertaVencimento: config.diasAlertaVencimento
};
}
});
/**
* Atualizar configurações LGPD (apenas TI)
*/
export const atualizarConfiguracaoLGPD = mutation({
args: {
encarregadoNome: v.optional(v.string()),
encarregadoEmail: v.optional(v.string()),
encarregadoTelefone: v.optional(v.string()),
prazoRespostaPadrao: v.optional(v.number()),
diasAlertaVencimento: v.optional(v.number())
},
returns: v.object({ sucesso: v.boolean() }),
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
// Buscar configuração ativa ou criar nova
let config = await ctx.db
.query('configuracaoLGPD')
.withIndex('by_ativo', (q) => q.eq('ativo', true))
.first();
if (config) {
// Desativar configuração antiga
await ctx.db.patch(config._id, { ativo: false });
}
// Criar nova configuração
await ctx.db.insert('configuracaoLGPD', {
encarregadoNome: args.encarregadoNome,
encarregadoEmail: args.encarregadoEmail,
encarregadoTelefone: args.encarregadoTelefone,
prazoRespostaPadrao: args.prazoRespostaPadrao ?? 15,
diasAlertaVencimento: args.diasAlertaVencimento ?? 3,
ativo: true,
atualizadoPor: usuario._id,
atualizadoEm: Date.now()
});
// Log de atividade
await registrarAtividade(
ctx,
usuario._id,
'atualizar_config_lgpd',
'configuracaoLGPD',
JSON.stringify(args),
''
);
return { sucesso: true };
}
});
/**
* Obter estatísticas LGPD (apenas TI)
*/
export const obterEstatisticasLGPD = query({
args: {},
returns: v.object({
totalSolicitacoes: v.number(),
solicitacoesPendentes: v.number(),
solicitacoesVencendo: v.number(),
solicitacoesPorTipo: v.record(v.string(), v.number()),
totalConsentimentos: v.number(),
consentimentosAtivos: v.number(),
totalROTs: v.number(),
rotsAtivos: v.number()
}),
handler: async (ctx) => {
const solicitacoes = await ctx.db.query('solicitacoesLGPD').collect();
const consentimentos = await ctx.db.query('consentimentos').collect();
const rots = await ctx.db.query('registrosTratamento').collect();
const agora = Date.now();
const tresDias = 3 * 24 * 60 * 60 * 1000;
const solicitacoesVencendo = solicitacoes.filter(
(s) =>
s.status === 'pendente' || s.status === 'em_analise'
? s.prazoResposta - agora <= tresDias && s.prazoResposta > agora
: false
).length;
const solicitacoesPorTipo: Record<string, number> = {};
solicitacoes.forEach((s) => {
solicitacoesPorTipo[s.tipo] = (solicitacoesPorTipo[s.tipo] || 0) + 1;
});
const consentimentosAtivos = consentimentos.filter(
(c) => c.aceito && !c.revogadoEm
).length;
return {
totalSolicitacoes: solicitacoes.length,
solicitacoesPendentes: solicitacoes.filter((s) => s.status === 'pendente').length,
solicitacoesVencendo,
solicitacoesPorTipo,
totalConsentimentos: consentimentos.length,
consentimentosAtivos,
totalROTs: rots.length,
rotsAtivos: rots.filter((r) => r.ativo).length
};
}
});

View File

@@ -1873,4 +1873,95 @@ export default defineSchema({
.index("by_ativo", ["ativo"]) .index("by_ativo", ["ativo"])
.index("by_data_inicio", ["dataInicio"]) .index("by_data_inicio", ["dataInicio"])
.index("by_data_fim", ["dataFim"]), .index("by_data_fim", ["dataFim"]),
// ========== LGPD - Lei Geral de Proteção de Dados ==========
// Solicitações de direitos LGPD
solicitacoesLGPD: defineTable({
tipo: v.union(
v.literal("acesso"),
v.literal("correcao"),
v.literal("exclusao"),
v.literal("portabilidade"),
v.literal("revogacao_consentimento"),
v.literal("informacao_compartilhamento")
),
usuarioId: v.id("usuarios"),
funcionarioId: v.optional(v.id("funcionarios")),
status: v.union(
v.literal("pendente"),
v.literal("em_analise"),
v.literal("concluida"),
v.literal("rejeitada")
),
dadosSolicitados: v.optional(v.string()), // JSON com detalhes da solicitação
resposta: v.optional(v.string()), // Resposta da solicitação
arquivoResposta: v.optional(v.id("_storage")), // Arquivo gerado (ex: exportação de dados)
respondidoPor: v.optional(v.id("usuarios")),
respondidoEm: v.optional(v.number()),
criadoEm: v.number(),
prazoResposta: v.number(), // Prazo legal (15 dias) - timestamp
observacoes: v.optional(v.string()),
})
.index("by_usuario", ["usuarioId"])
.index("by_status", ["status"])
.index("by_tipo", ["tipo"])
.index("by_prazo", ["prazoResposta"])
.index("by_funcionario", ["funcionarioId"]),
// Consentimentos dos usuários
consentimentos: defineTable({
usuarioId: v.id("usuarios"),
tipo: v.union(
v.literal("termo_uso"),
v.literal("politica_privacidade"),
v.literal("comunicacoes"),
v.literal("compartilhamento_dados")
),
aceito: v.boolean(),
versao: v.string(), // Versão do documento aceito (ex: "1.0")
ipAddress: v.optional(v.string()),
userAgent: v.optional(v.string()),
aceitoEm: v.number(),
revogadoEm: v.optional(v.number()),
revogadoPor: v.optional(v.id("usuarios")), // Se revogado pelo próprio usuário ou por TI
})
.index("by_usuario", ["usuarioId"])
.index("by_tipo", ["tipo"])
.index("by_usuario_tipo", ["usuarioId", "tipo"])
.index("by_versao", ["versao"]),
// Registro de Operações de Tratamento (ROT)
registrosTratamento: defineTable({
finalidade: v.string(), // Finalidade do tratamento
baseLegal: v.string(), // Base legal (ex: "Art. 7º, II - Execução de políticas públicas")
categoriasDados: v.array(v.string()), // ["dados_identificacao", "dados_contato", "dados_profissionais"]
categoriasTitulares: v.array(v.string()), // ["funcionarios", "servidores", "colaboradores"]
medidasSeguranca: v.array(v.string()), // ["criptografia", "controle_acesso", "logs_auditoria"]
prazoRetencao: v.number(), // em dias
compartilhamentoTerceiros: v.boolean(),
terceiros: v.optional(v.array(v.string())), // Lista de terceiros com quem compartilha
responsavel: v.id("usuarios"), // Responsável pelo tratamento
criadoEm: v.number(),
atualizadoEm: v.number(),
ativo: v.boolean(),
descricao: v.optional(v.string()), // Descrição detalhada
})
.index("by_finalidade", ["finalidade"])
.index("by_ativo", ["ativo"])
.index("by_responsavel", ["responsavel"]),
// Configurações LGPD
configuracaoLGPD: defineTable({
encarregadoNome: v.optional(v.string()),
encarregadoEmail: v.optional(v.string()),
encarregadoTelefone: v.optional(v.string()),
prazoRespostaPadrao: v.number(), // em dias (padrão: 15)
diasAlertaVencimento: v.number(), // dias antes do prazo para alertar (padrão: 3)
politicaRetencao: v.optional(v.string()), // JSON com política de retenção por tipo de dado
ativo: v.boolean(),
atualizadoPor: v.id("usuarios"),
atualizadoEm: v.number(),
})
.index("by_ativo", ["ativo"]),
}); });