feat: implement cancellation and deletion functionality for LGPD requests; enhance UI with confirmation modals and update backend to support new operations

This commit is contained in:
2025-12-03 16:57:10 -03:00
parent b145fcc74a
commit 4a662c08a0
5 changed files with 549 additions and 59 deletions

View File

@@ -2,6 +2,7 @@
import { resolve } from '$app/paths';
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import {
Shield,
FileText,
@@ -10,7 +11,9 @@
Clock,
XCircle,
AlertCircle,
Send
Send,
Trash2,
X
} from 'lucide-svelte';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
@@ -28,17 +31,29 @@
let dadosSolicitados = $state('');
let observacoes = $state('');
let carregando = $state(false);
let cancelandoId = $state<Id<'solicitacoesLGPD'> | null>(null);
let excluindoId = $state<Id<'solicitacoesLGPD'> | null>(null);
let mostrarConfirmacaoExclusao = $state(false);
let solicitacaoParaExcluir = $state<Id<'solicitacoesLGPD'> | null>(null);
const client = useConvexClient();
const minhasSolicitacoesQuery = useQuery(api.lgpd.listarMinhasSolicitacoes, {});
// Estado para forçar atualização das queries
let refreshKeyLGPD = $state(0);
const minhasSolicitacoesQuery = useQuery(api.lgpd.listarMinhasSolicitacoes, {
_refresh: refreshKeyLGPD
});
// Garantir que sempre seja um array ou undefined
const minhasSolicitacoes = $derived(
minhasSolicitacoesQuery === undefined || minhasSolicitacoesQuery === null
? undefined
: Array.isArray(minhasSolicitacoesQuery)
? minhasSolicitacoesQuery
: []
: Array.isArray(minhasSolicitacoesQuery?.data)
? minhasSolicitacoesQuery.data
: minhasSolicitacoesQuery?.data === null
? []
: []
);
const exportarDados = useQuery(api.lgpd.exportarDadosUsuario, {});
@@ -86,6 +101,8 @@
return { label: 'Concluída', class: 'badge-success' };
case 'rejeitada':
return { label: 'Rejeitada', class: 'badge-error' };
case 'cancelada':
return { label: 'Cancelada', class: 'badge-neutral' };
default:
return { label: status, class: 'badge-neutral' };
}
@@ -101,6 +118,8 @@
return CheckCircle;
case 'rejeitada':
return XCircle;
case 'cancelada':
return XCircle;
default:
return FileText;
}
@@ -125,6 +144,8 @@
tipoSelecionado = null;
dadosSolicitados = '';
observacoes = '';
// Forçar atualização das queries
refreshKeyLGPD++;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Erro ao criar solicitação';
toast.error(message);
@@ -150,6 +171,56 @@
URL.revokeObjectURL(url);
toast.success('Download iniciado!');
}
async function cancelarSolicitacao(solicitacaoId: Id<'solicitacoesLGPD'>) {
if (cancelandoId === solicitacaoId) return;
cancelandoId = solicitacaoId;
try {
await client.mutation(api.lgpd.cancelarSolicitacao, {
solicitacaoId
});
toast.success('Solicitação cancelada com sucesso!');
refreshKeyLGPD++;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Erro ao cancelar solicitação';
toast.error(message);
} finally {
cancelandoId = null;
}
}
function solicitarExclusao(solicitacaoId: Id<'solicitacoesLGPD'>) {
solicitacaoParaExcluir = solicitacaoId;
mostrarConfirmacaoExclusao = true;
}
function fecharConfirmacaoExclusao() {
mostrarConfirmacaoExclusao = false;
solicitacaoParaExcluir = null;
}
async function confirmarExclusao() {
if (!solicitacaoParaExcluir) return;
const solicitacaoId = solicitacaoParaExcluir;
excluindoId = solicitacaoId;
mostrarConfirmacaoExclusao = false;
try {
await client.mutation(api.lgpd.excluirSolicitacao, {
solicitacaoId
});
toast.success('Solicitação excluída com sucesso!');
refreshKeyLGPD++;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Erro ao excluir solicitação';
toast.error(message);
} finally {
excluindoId = null;
solicitacaoParaExcluir = null;
}
}
</script>
<div class="container mx-auto max-w-6xl px-4 py-8">
@@ -324,6 +395,40 @@
})}
</div>
{/if}
<!-- Ações -->
<div class="mt-4 flex gap-2 border-t border-base-300 pt-3">
{#if solicitacao.status === 'pendente' || solicitacao.status === 'em_analise'}
<button
type="button"
onclick={() => cancelarSolicitacao(solicitacao._id)}
disabled={cancelandoId === solicitacao._id}
class="btn btn-sm btn-warning btn-outline gap-2"
>
{#if cancelandoId === solicitacao._id}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<X class="h-4 w-4" />
{/if}
Cancelar
</button>
{/if}
{#if solicitacao.status === 'pendente' || solicitacao.status === 'cancelada'}
<button
type="button"
onclick={() => solicitarExclusao(solicitacao._id)}
disabled={excluindoId === solicitacao._id}
class="btn btn-sm btn-error btn-outline gap-2"
>
{#if excluindoId === solicitacao._id}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Trash2 class="h-4 w-4" />
{/if}
Excluir
</button>
{/if}
</div>
</div>
{/each}
</div>
@@ -394,3 +499,35 @@
</div>
</div>
</div>
<!-- Modal de Confirmação de Exclusão -->
{#if mostrarConfirmacaoExclusao}
<dialog class="modal modal-open">
<div class="modal-box">
<h3 class="mb-4 text-lg font-bold">Confirmar Exclusão</h3>
<p class="mb-6 text-base-content/70">
Tem certeza que deseja excluir esta solicitação? Esta ação não pode ser desfeita.
</p>
<div class="modal-action">
<button type="button" onclick={fecharConfirmacaoExclusao} class="btn btn-ghost">
Cancelar
</button>
<button
type="button"
onclick={confirmarExclusao}
disabled={excluindoId !== null}
class="btn btn-error gap-2"
>
{#if excluindoId}
<span class="loading loading-spinner loading-sm"></span>
Excluindo...
{:else}
<Trash2 class="h-4 w-4" />
Excluir
{/if}
</button>
</div>
</div>
<div class="modal-backdrop" onclick={fecharConfirmacaoExclusao}></div>
</dialog>
{/if}

View File

@@ -11,7 +11,12 @@
XCircle,
AlertCircle,
Search,
Filter
Filter,
Upload,
Download,
User,
Mail,
Hash
} from 'lucide-svelte';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
@@ -39,12 +44,18 @@
tipo: tipoFiltro || undefined
});
const solicitacoes = useQuery(api.lgpd.listarSolicitacoes, solicitacoesQuery);
const solicitacoesQueryResult = useQuery(api.lgpd.listarSolicitacoes, solicitacoesQuery);
const solicitacoes = $derived(solicitacoesQueryResult?.data || []);
let solicitacaoSelecionada = $state<string | null>(null);
let solicitacaoDetalhes = $derived(
solicitacoesFiltradas.find((s) => s._id === solicitacaoSelecionada) || null
);
let resposta = $state('');
let statusResposta = $state<'concluida' | 'rejeitada' | 'em_analise'>('concluida');
let carregando = $state(false);
let arquivoResposta = $state<File | null>(null);
let uploadandoArquivo = $state(false);
function getStatusBadge(status: string) {
switch (status) {
@@ -90,7 +101,7 @@
function filtrarSolicitacoes() {
// Verificar se solicitacoes existe e é um array
if (!solicitacoes || !Array.isArray(solicitacoes)) return [];
if (!Array.isArray(solicitacoes) || solicitacoes.length === 0) return [];
// Se não há termo de busca, retorna todas as solicitações
if (!termoBusca || termoBusca.trim() === '') return solicitacoes;
@@ -107,24 +118,61 @@
);
}
async function uploadArquivo(): Promise<Id<'_storage'> | null> {
if (!arquivoResposta) return null;
uploadandoArquivo = true;
try {
const storageId = await client.storage.store(arquivoResposta);
return storageId;
} catch (error) {
console.error('Erro ao fazer upload do arquivo:', error);
toast.error('Erro ao fazer upload do arquivo');
return null;
} finally {
uploadandoArquivo = false;
}
}
async function responder() {
if (!solicitacaoSelecionada || !resposta.trim()) {
toast.error('Preencha a resposta');
return;
}
// Validações específicas por tipo
const tipo = solicitacaoDetalhes?.tipo;
if (tipo === 'portabilidade' && statusResposta === 'concluida' && !arquivoResposta) {
toast.error('Para solicitações de portabilidade concluídas, é necessário anexar o arquivo com os dados');
return;
}
carregando = true;
try {
// Fazer upload do arquivo se houver
let arquivoStorageId: Id<'_storage'> | undefined = undefined;
if (arquivoResposta) {
const uploadedId = await uploadArquivo();
if (uploadedId) {
arquivoStorageId = uploadedId;
} else {
carregando = false;
return;
}
}
await client.mutation(api.lgpd.responderSolicitacao, {
solicitacaoId: solicitacaoSelecionada as Id<'solicitacoesLGPD'>,
resposta: resposta.trim(),
status: statusResposta
status: statusResposta,
arquivoResposta: arquivoStorageId
});
toast.success('Solicitação respondida com sucesso!');
solicitacaoSelecionada = null;
resposta = '';
arquivoResposta = null;
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Erro ao responder solicitação';
toast.error(message);
@@ -133,6 +181,18 @@
}
}
function getInstrucoesResposta(tipo: string): string {
const instrucoes: Record<string, string> = {
acesso: 'Informe quais dados pessoais estão sendo tratados e forneça acesso aos mesmos conforme solicitado.',
correcao: 'Confirme as correções realizadas ou explique o motivo caso não seja possível corrigir.',
exclusao: 'Confirme a exclusão dos dados ou explique o motivo caso não seja possível excluir (ex: obrigação legal de retenção).',
portabilidade: 'Anexe o arquivo com os dados em formato estruturado e de uso comum (JSON, CSV, etc).',
revogacao_consentimento: 'Confirme a revogação do consentimento e informe sobre as consequências (se houver).',
informacao_compartilhamento: 'Informe com quais terceiros os dados são compartilhados e para quais finalidades.'
};
return instrucoes[tipo] || 'Forneça uma resposta clara e objetiva sobre a solicitação.';
}
const solicitacoesFiltradas = $derived(filtrarSolicitacoes());
</script>
@@ -208,19 +268,19 @@
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="mb-4 flex items-center justify-between">
<h2 class="card-title text-2xl">Solicitações</h2>
{#if solicitacoes && Array.isArray(solicitacoes)}
<h2 class="card-title text-2xl">Solicitações Recebidas</h2>
{#if Array.isArray(solicitacoes) && solicitacoes.length > 0}
<div class="badge badge-outline">
Total: {solicitacoes.length} | Exibindo: {solicitacoesFiltradas.length}
</div>
{/if}
</div>
{#if solicitacoes === undefined || solicitacoes === null}
{#if solicitacoesQueryResult === undefined}
<div class="flex items-center justify-center py-20">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if !Array.isArray(solicitacoes) || solicitacoes.length === 0}
{:else if solicitacoes.length === 0}
<div class="py-10 text-center">
<FileText class="text-base-content/30 mx-auto mb-4 h-16 w-16" />
<p class="text-base-content/60">
@@ -269,18 +329,70 @@
<span class="badge {statusInfo.class}">{statusInfo.label}</span>
</div>
<div class="text-base-content/70 space-y-1 text-sm">
<div>
<span class="font-semibold">Solicitante:</span>
{solicitacao.usuarioNome}
{#if solicitacao.usuarioMatricula}
({solicitacao.usuarioMatricula})
{/if}
<div class="text-base-content/70 space-y-2 text-sm">
<div class="flex items-center gap-2">
<User class="h-4 w-4 text-base-content/50" />
<div>
<span class="font-semibold">Solicitante:</span> {solicitacao.usuarioNome}
{#if solicitacao.usuarioMatricula}
<span class="text-base-content/50"> ({solicitacao.usuarioMatricula})</span>
{/if}
</div>
</div>
<div>
<span class="font-semibold">E-mail:</span>
{solicitacao.usuarioEmail}
<div class="flex items-center gap-2">
<Mail class="h-4 w-4 text-base-content/50" />
<div>
<span class="font-semibold">E-mail:</span> {solicitacao.usuarioEmail}
</div>
</div>
{#if solicitacao.usuarioMatricula}
<div class="flex items-center gap-2">
<Hash class="h-4 w-4 text-base-content/50" />
<div>
<span class="font-semibold">Matrícula:</span> {solicitacao.usuarioMatricula}
</div>
</div>
{/if}
{#if solicitacao.dadosSolicitados}
<div class="mt-2 rounded-lg bg-base-200 p-3">
<p class="mb-1 text-xs font-semibold uppercase text-base-content/60">
Dados Solicitados:
</p>
<p class="text-base-content/80 whitespace-pre-wrap text-sm">
{solicitacao.dadosSolicitados}
</p>
</div>
{/if}
{#if solicitacao.observacoes}
<div class="mt-2 rounded-lg bg-base-200 p-3">
<p class="mb-1 text-xs font-semibold uppercase text-base-content/60">
Observações do Solicitante:
</p>
<p class="text-base-content/80 whitespace-pre-wrap text-sm">
{solicitacao.observacoes}
</p>
</div>
{/if}
{#if solicitacao.resposta}
<div class="mt-2 rounded-lg bg-success/10 border border-success/20 p-3">
<p class="mb-1 text-xs font-semibold uppercase text-success">Resposta:</p>
<p class="text-base-content/80 whitespace-pre-wrap text-sm">
{solicitacao.resposta}
</p>
{#if solicitacao.arquivoResposta}
<div class="mt-2">
<a
href={solicitacao.arquivoResposta}
target="_blank"
class="btn btn-sm btn-outline btn-success gap-2"
>
<Download class="h-4 w-4" />
Baixar Arquivo de Resposta
</a>
</div>
{/if}
</div>
{/if}
{#if solicitacao.consentimentoTermo}
<div class="flex items-center gap-2">
<span class="font-semibold">Termo de Consentimento:</span>
@@ -346,20 +458,74 @@
</div>
<!-- Modal de Resposta -->
{#if solicitacaoSelecionada}
{#if solicitacaoSelecionada && solicitacaoDetalhes}
<div class="modal modal-open">
<div class="modal-box">
<div class="modal-box max-w-3xl max-h-[90vh] overflow-y-auto">
<h3 class="mb-4 text-lg font-bold">Responder Solicitação</h3>
<!-- Informações da Solicitação -->
<div class="mb-6 rounded-lg bg-base-200 p-4">
<div class="mb-3">
<h4 class="mb-2 font-semibold">Tipo de Solicitação:</h4>
<p class="text-base-content/80">{getTipoLabel(solicitacaoDetalhes.tipo)}</p>
</div>
<div class="mb-3">
<h4 class="mb-2 font-semibold">Solicitante:</h4>
<p class="text-base-content/80">
{solicitacaoDetalhes.usuarioNome}
{#if solicitacaoDetalhes.usuarioMatricula}
<span class="text-base-content/50"> ({solicitacaoDetalhes.usuarioMatricula})</span>
{/if}
</p>
<p class="text-base-content/60 text-sm">{solicitacaoDetalhes.usuarioEmail}</p>
</div>
{#if solicitacaoDetalhes.dadosSolicitados}
<div class="mb-3">
<h4 class="mb-2 font-semibold">Dados Solicitados:</h4>
<p class="text-base-content/80 whitespace-pre-wrap text-sm">
{solicitacaoDetalhes.dadosSolicitados}
</p>
</div>
{/if}
{#if solicitacaoDetalhes.observacoes}
<div>
<h4 class="mb-2 font-semibold">Observações:</h4>
<p class="text-base-content/80 whitespace-pre-wrap text-sm">
{solicitacaoDetalhes.observacoes}
</p>
</div>
{/if}
</div>
<!-- Instruções -->
<div class="alert alert-info mb-4">
<AlertCircle class="h-5 w-5" />
<div class="text-sm">
<p class="font-semibold">Instruções:</p>
<p>{getInstrucoesResposta(solicitacaoDetalhes.tipo)}</p>
</div>
</div>
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-semibold">Status</span>
<span class="label-text font-semibold">Status da Resposta *</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>
<label class="label">
<span class="label-text-alt text-base-content/60">
{#if statusResposta === 'concluida'}
A solicitação foi atendida com sucesso
{:else if statusResposta === 'rejeitada'}
A solicitação foi rejeitada (informe o motivo na resposta)
{:else}
A solicitação está em análise e será respondida posteriormente
{/if}
</span>
</label>
</div>
<div class="form-control mb-4">
@@ -372,33 +538,95 @@
placeholder="Digite a resposta para o solicitante..."
rows="6"
></textarea>
<label class="label">
<span class="label-text-alt text-base-content/60">
Forneça uma resposta clara e objetiva conforme as regras da LGPD
</span>
</label>
</div>
<!-- Upload de Arquivo (obrigatório para portabilidade concluída) -->
{#if solicitacaoDetalhes.tipo === 'portabilidade' || solicitacaoDetalhes.tipo === 'acesso'}
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-semibold">
Arquivo de Resposta
{#if solicitacaoDetalhes.tipo === 'portabilidade' && statusResposta === 'concluida'}
<span class="text-error">*</span>
{/if}
</span>
</label>
<input
type="file"
accept=".json,.csv,.txt,.pdf"
onchange={(e) => {
const file = (e.target as HTMLInputElement).files?.[0];
arquivoResposta = file || null;
}}
class="file-input file-input-bordered w-full"
/>
<label class="label">
<span class="label-text-alt text-base-content/60">
{#if solicitacaoDetalhes.tipo === 'portabilidade'}
Para portabilidade, anexe o arquivo com os dados em formato estruturado (JSON, CSV,
etc)
{:else}
Opcional: Anexe arquivo com informações adicionais ou dados exportados
{/if}
</span>
</label>
{#if arquivoResposta}
<div class="mt-2 flex items-center gap-2">
<FileText class="h-4 w-4 text-base-content/60" />
<span class="text-sm text-base-content/80">{arquivoResposta.name}</span>
<button
type="button"
onclick={() => (arquivoResposta = null)}
class="btn btn-xs btn-ghost"
>
Remover
</button>
</div>
{/if}
</div>
{/if}
<div class="modal-action">
<button
onclick={() => {
solicitacaoSelecionada = null;
resposta = '';
arquivoResposta = null;
}}
class="btn btn-ghost"
disabled={carregando}
>
Cancelar
</button>
<button
onclick={responder}
disabled={!resposta.trim() || carregando}
class="btn btn-primary"
disabled={!resposta.trim() || carregando || uploadandoArquivo}
class="btn btn-primary gap-2"
>
{#if carregando}
{#if carregando || uploadandoArquivo}
<span class="loading loading-spinner loading-sm"></span>
Enviando...
{uploadandoArquivo ? 'Enviando arquivo...' : 'Enviando...'}
{:else}
Enviar Resposta
{/if}
</button>
</div>
</div>
<div class="modal-backdrop" onclick={() => (solicitacaoSelecionada = null)}></div>
<div
class="modal-backdrop"
onclick={() => {
if (!carregando) {
solicitacaoSelecionada = null;
resposta = '';
arquivoResposta = null;
}
}}
></div>
</div>
{/if}
</div>