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

@@ -268,6 +268,19 @@
palette: 'success',
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',
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 { api } from "@sgse-app/backend/convex/_generated/api";
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";
const client = useConvexClient();
const usuariosQuery = useQuery(api.usuarios.listar, {});
const estatisticasLGPD = useQuery(api.lgpd.obterEstatisticasLGPD, {});
// Verificar se está carregando
const carregando = $derived(usuariosQuery === undefined);
@@ -96,6 +97,54 @@
</div>
{/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 -->
<div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body">
@@ -115,6 +164,11 @@
<FileText class="h-5 w-5" strokeWidth={2} />
Ver Logs
</a>
<a href={resolve("/ti/lgpd")} class="btn btn-info">
<Shield class="h-5 w-5" strokeWidth={2} />
LGPD
</a>
</div>
</div>
</div>