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:
@@ -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:
|
||||
|
||||
134
apps/web/src/routes/(dashboard)/ti/lgpd/+page.svelte
Normal file
134
apps/web/src/routes/(dashboard)/ti/lgpd/+page.svelte
Normal 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. 7º, 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user