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:
@@ -2,6 +2,7 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
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 type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
import {
|
import {
|
||||||
Shield,
|
Shield,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -10,7 +11,9 @@
|
|||||||
Clock,
|
Clock,
|
||||||
XCircle,
|
XCircle,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Send
|
Send,
|
||||||
|
Trash2,
|
||||||
|
X
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { ptBR } from 'date-fns/locale';
|
import { ptBR } from 'date-fns/locale';
|
||||||
@@ -28,16 +31,28 @@
|
|||||||
let dadosSolicitados = $state('');
|
let dadosSolicitados = $state('');
|
||||||
let observacoes = $state('');
|
let observacoes = $state('');
|
||||||
let carregando = $state(false);
|
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 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
|
// Garantir que sempre seja um array ou undefined
|
||||||
const minhasSolicitacoes = $derived(
|
const minhasSolicitacoes = $derived(
|
||||||
minhasSolicitacoesQuery === undefined || minhasSolicitacoesQuery === null
|
minhasSolicitacoesQuery === undefined || minhasSolicitacoesQuery === null
|
||||||
? undefined
|
? undefined
|
||||||
: Array.isArray(minhasSolicitacoesQuery)
|
: Array.isArray(minhasSolicitacoesQuery?.data)
|
||||||
? minhasSolicitacoesQuery
|
? minhasSolicitacoesQuery.data
|
||||||
|
: minhasSolicitacoesQuery?.data === null
|
||||||
|
? []
|
||||||
: []
|
: []
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -86,6 +101,8 @@
|
|||||||
return { label: 'Concluída', class: 'badge-success' };
|
return { label: 'Concluída', class: 'badge-success' };
|
||||||
case 'rejeitada':
|
case 'rejeitada':
|
||||||
return { label: 'Rejeitada', class: 'badge-error' };
|
return { label: 'Rejeitada', class: 'badge-error' };
|
||||||
|
case 'cancelada':
|
||||||
|
return { label: 'Cancelada', class: 'badge-neutral' };
|
||||||
default:
|
default:
|
||||||
return { label: status, class: 'badge-neutral' };
|
return { label: status, class: 'badge-neutral' };
|
||||||
}
|
}
|
||||||
@@ -101,6 +118,8 @@
|
|||||||
return CheckCircle;
|
return CheckCircle;
|
||||||
case 'rejeitada':
|
case 'rejeitada':
|
||||||
return XCircle;
|
return XCircle;
|
||||||
|
case 'cancelada':
|
||||||
|
return XCircle;
|
||||||
default:
|
default:
|
||||||
return FileText;
|
return FileText;
|
||||||
}
|
}
|
||||||
@@ -125,6 +144,8 @@
|
|||||||
tipoSelecionado = null;
|
tipoSelecionado = null;
|
||||||
dadosSolicitados = '';
|
dadosSolicitados = '';
|
||||||
observacoes = '';
|
observacoes = '';
|
||||||
|
// Forçar atualização das queries
|
||||||
|
refreshKeyLGPD++;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Erro ao criar solicitação';
|
const message = error instanceof Error ? error.message : 'Erro ao criar solicitação';
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
@@ -150,6 +171,56 @@
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
toast.success('Download iniciado!');
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="container mx-auto max-w-6xl px-4 py-8">
|
<div class="container mx-auto max-w-6xl px-4 py-8">
|
||||||
@@ -324,6 +395,40 @@
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -394,3 +499,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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}
|
||||||
|
|||||||
@@ -11,7 +11,12 @@
|
|||||||
XCircle,
|
XCircle,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Search,
|
Search,
|
||||||
Filter
|
Filter,
|
||||||
|
Upload,
|
||||||
|
Download,
|
||||||
|
User,
|
||||||
|
Mail,
|
||||||
|
Hash
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { ptBR } from 'date-fns/locale';
|
import { ptBR } from 'date-fns/locale';
|
||||||
@@ -39,12 +44,18 @@
|
|||||||
tipo: tipoFiltro || undefined
|
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 solicitacaoSelecionada = $state<string | null>(null);
|
||||||
|
let solicitacaoDetalhes = $derived(
|
||||||
|
solicitacoesFiltradas.find((s) => s._id === solicitacaoSelecionada) || null
|
||||||
|
);
|
||||||
let resposta = $state('');
|
let resposta = $state('');
|
||||||
let statusResposta = $state<'concluida' | 'rejeitada' | 'em_analise'>('concluida');
|
let statusResposta = $state<'concluida' | 'rejeitada' | 'em_analise'>('concluida');
|
||||||
let carregando = $state(false);
|
let carregando = $state(false);
|
||||||
|
let arquivoResposta = $state<File | null>(null);
|
||||||
|
let uploadandoArquivo = $state(false);
|
||||||
|
|
||||||
function getStatusBadge(status: string) {
|
function getStatusBadge(status: string) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -90,7 +101,7 @@
|
|||||||
|
|
||||||
function filtrarSolicitacoes() {
|
function filtrarSolicitacoes() {
|
||||||
// Verificar se solicitacoes existe e é um array
|
// 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
|
// Se não há termo de busca, retorna todas as solicitações
|
||||||
if (!termoBusca || termoBusca.trim() === '') return solicitacoes;
|
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() {
|
async function responder() {
|
||||||
if (!solicitacaoSelecionada || !resposta.trim()) {
|
if (!solicitacaoSelecionada || !resposta.trim()) {
|
||||||
toast.error('Preencha a resposta');
|
toast.error('Preencha a resposta');
|
||||||
return;
|
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;
|
carregando = true;
|
||||||
|
|
||||||
try {
|
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, {
|
await client.mutation(api.lgpd.responderSolicitacao, {
|
||||||
solicitacaoId: solicitacaoSelecionada as Id<'solicitacoesLGPD'>,
|
solicitacaoId: solicitacaoSelecionada as Id<'solicitacoesLGPD'>,
|
||||||
resposta: resposta.trim(),
|
resposta: resposta.trim(),
|
||||||
status: statusResposta
|
status: statusResposta,
|
||||||
|
arquivoResposta: arquivoStorageId
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success('Solicitação respondida com sucesso!');
|
toast.success('Solicitação respondida com sucesso!');
|
||||||
solicitacaoSelecionada = null;
|
solicitacaoSelecionada = null;
|
||||||
resposta = '';
|
resposta = '';
|
||||||
|
arquivoResposta = null;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const message = error instanceof Error ? error.message : 'Erro ao responder solicitação';
|
const message = error instanceof Error ? error.message : 'Erro ao responder solicitação';
|
||||||
toast.error(message);
|
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());
|
const solicitacoesFiltradas = $derived(filtrarSolicitacoes());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -208,19 +268,19 @@
|
|||||||
<div class="card bg-base-100 shadow-xl">
|
<div class="card bg-base-100 shadow-xl">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
<h2 class="card-title text-2xl">Solicitações</h2>
|
<h2 class="card-title text-2xl">Solicitações Recebidas</h2>
|
||||||
{#if solicitacoes && Array.isArray(solicitacoes)}
|
{#if Array.isArray(solicitacoes) && solicitacoes.length > 0}
|
||||||
<div class="badge badge-outline">
|
<div class="badge badge-outline">
|
||||||
Total: {solicitacoes.length} | Exibindo: {solicitacoesFiltradas.length}
|
Total: {solicitacoes.length} | Exibindo: {solicitacoesFiltradas.length}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if solicitacoes === undefined || solicitacoes === null}
|
{#if solicitacoesQueryResult === undefined}
|
||||||
<div class="flex items-center justify-center py-20">
|
<div class="flex items-center justify-center py-20">
|
||||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
</div>
|
</div>
|
||||||
{:else if !Array.isArray(solicitacoes) || solicitacoes.length === 0}
|
{:else if solicitacoes.length === 0}
|
||||||
<div class="py-10 text-center">
|
<div class="py-10 text-center">
|
||||||
<FileText class="text-base-content/30 mx-auto mb-4 h-16 w-16" />
|
<FileText class="text-base-content/30 mx-auto mb-4 h-16 w-16" />
|
||||||
<p class="text-base-content/60">
|
<p class="text-base-content/60">
|
||||||
@@ -269,18 +329,70 @@
|
|||||||
<span class="badge {statusInfo.class}">{statusInfo.label}</span>
|
<span class="badge {statusInfo.class}">{statusInfo.label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-base-content/70 space-y-1 text-sm">
|
<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>
|
<div>
|
||||||
<span class="font-semibold">Solicitante:</span>
|
<span class="font-semibold">Solicitante:</span> {solicitacao.usuarioNome}
|
||||||
{solicitacao.usuarioNome}
|
|
||||||
{#if solicitacao.usuarioMatricula}
|
{#if solicitacao.usuarioMatricula}
|
||||||
({solicitacao.usuarioMatricula})
|
<span class="text-base-content/50"> ({solicitacao.usuarioMatricula})</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<span class="font-semibold">E-mail:</span>
|
|
||||||
{solicitacao.usuarioEmail}
|
|
||||||
</div>
|
</div>
|
||||||
|
<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}
|
{#if solicitacao.consentimentoTermo}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-semibold">Termo de Consentimento:</span>
|
<span class="font-semibold">Termo de Consentimento:</span>
|
||||||
@@ -346,20 +458,74 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal de Resposta -->
|
<!-- Modal de Resposta -->
|
||||||
{#if solicitacaoSelecionada}
|
{#if solicitacaoSelecionada && solicitacaoDetalhes}
|
||||||
<div class="modal modal-open">
|
<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>
|
<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">
|
<div class="form-control mb-4">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text font-semibold">Status</span>
|
<span class="label-text font-semibold">Status da Resposta *</span>
|
||||||
</label>
|
</label>
|
||||||
<select bind:value={statusResposta} class="select select-bordered">
|
<select bind:value={statusResposta} class="select select-bordered">
|
||||||
<option value="concluida">Concluída</option>
|
<option value="concluida">Concluída</option>
|
||||||
<option value="rejeitada">Rejeitada</option>
|
<option value="rejeitada">Rejeitada</option>
|
||||||
<option value="em_analise">Em Análise</option>
|
<option value="em_analise">Em Análise</option>
|
||||||
</select>
|
</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>
|
||||||
|
|
||||||
<div class="form-control mb-4">
|
<div class="form-control mb-4">
|
||||||
@@ -372,33 +538,95 @@
|
|||||||
placeholder="Digite a resposta para o solicitante..."
|
placeholder="Digite a resposta para o solicitante..."
|
||||||
rows="6"
|
rows="6"
|
||||||
></textarea>
|
></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>
|
</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">
|
<div class="modal-action">
|
||||||
<button
|
<button
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
solicitacaoSelecionada = null;
|
solicitacaoSelecionada = null;
|
||||||
resposta = '';
|
resposta = '';
|
||||||
|
arquivoResposta = null;
|
||||||
}}
|
}}
|
||||||
class="btn btn-ghost"
|
class="btn btn-ghost"
|
||||||
|
disabled={carregando}
|
||||||
>
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={responder}
|
onclick={responder}
|
||||||
disabled={!resposta.trim() || carregando}
|
disabled={!resposta.trim() || carregando || uploadandoArquivo}
|
||||||
class="btn btn-primary"
|
class="btn btn-primary gap-2"
|
||||||
>
|
>
|
||||||
{#if carregando}
|
{#if carregando || uploadandoArquivo}
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
Enviando...
|
{uploadandoArquivo ? 'Enviando arquivo...' : 'Enviando...'}
|
||||||
{:else}
|
{:else}
|
||||||
Enviar Resposta
|
Enviar Resposta
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-backdrop" onclick={() => (solicitacaoSelecionada = null)}></div>
|
<div
|
||||||
|
class="modal-backdrop"
|
||||||
|
onclick={() => {
|
||||||
|
if (!carregando) {
|
||||||
|
solicitacaoSelecionada = null;
|
||||||
|
resposta = '';
|
||||||
|
arquivoResposta = null;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -289,9 +289,11 @@ export const listarMinhasSolicitacoes = query({
|
|||||||
v.literal('pendente'),
|
v.literal('pendente'),
|
||||||
v.literal('em_analise'),
|
v.literal('em_analise'),
|
||||||
v.literal('concluida'),
|
v.literal('concluida'),
|
||||||
v.literal('rejeitada')
|
v.literal('rejeitada'),
|
||||||
)
|
v.literal('cancelada')
|
||||||
)
|
)
|
||||||
|
),
|
||||||
|
_refresh: v.optional(v.number()) // Parâmetro para forçar atualização no frontend
|
||||||
},
|
},
|
||||||
returns: v.array(
|
returns: v.array(
|
||||||
v.object({
|
v.object({
|
||||||
@@ -357,7 +359,8 @@ export const listarSolicitacoes = query({
|
|||||||
v.literal('pendente'),
|
v.literal('pendente'),
|
||||||
v.literal('em_analise'),
|
v.literal('em_analise'),
|
||||||
v.literal('concluida'),
|
v.literal('concluida'),
|
||||||
v.literal('rejeitada')
|
v.literal('rejeitada'),
|
||||||
|
v.literal('cancelada')
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
tipo: v.optional(
|
tipo: v.optional(
|
||||||
@@ -380,6 +383,10 @@ export const listarSolicitacoes = query({
|
|||||||
usuarioNome: v.string(),
|
usuarioNome: v.string(),
|
||||||
usuarioEmail: v.string(),
|
usuarioEmail: v.string(),
|
||||||
usuarioMatricula: v.union(v.string(), v.null()),
|
usuarioMatricula: v.union(v.string(), v.null()),
|
||||||
|
dadosSolicitados: v.union(v.string(), v.null()),
|
||||||
|
observacoes: v.union(v.string(), v.null()),
|
||||||
|
resposta: v.union(v.string(), v.null()),
|
||||||
|
arquivoResposta: v.union(v.string(), v.null()),
|
||||||
criadoEm: v.number(),
|
criadoEm: v.number(),
|
||||||
prazoResposta: v.number(),
|
prazoResposta: v.number(),
|
||||||
respondidoEm: v.union(v.number(), v.null()),
|
respondidoEm: v.union(v.number(), v.null()),
|
||||||
@@ -433,6 +440,10 @@ export const listarSolicitacoes = query({
|
|||||||
usuarioNome: string;
|
usuarioNome: string;
|
||||||
usuarioEmail: string;
|
usuarioEmail: string;
|
||||||
usuarioMatricula: string | null;
|
usuarioMatricula: string | null;
|
||||||
|
dadosSolicitados: string | null;
|
||||||
|
observacoes: string | null;
|
||||||
|
resposta: string | null;
|
||||||
|
arquivoResposta: string | null;
|
||||||
criadoEm: number;
|
criadoEm: number;
|
||||||
prazoResposta: number;
|
prazoResposta: number;
|
||||||
respondidoEm: number | null;
|
respondidoEm: number | null;
|
||||||
@@ -497,6 +508,12 @@ export const listarSolicitacoes = query({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Buscar URL do arquivo de resposta se existir
|
||||||
|
let arquivoRespostaUrl: string | null = null;
|
||||||
|
if (s.arquivoResposta) {
|
||||||
|
arquivoRespostaUrl = await ctx.storage.getUrl(s.arquivoResposta);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
_id: s._id,
|
_id: s._id,
|
||||||
tipo: s.tipo,
|
tipo: s.tipo,
|
||||||
@@ -504,6 +521,10 @@ export const listarSolicitacoes = query({
|
|||||||
usuarioNome: usuarioSolicitante?.nome ?? 'Usuário Desconhecido',
|
usuarioNome: usuarioSolicitante?.nome ?? 'Usuário Desconhecido',
|
||||||
usuarioEmail: usuarioSolicitante?.email ?? '',
|
usuarioEmail: usuarioSolicitante?.email ?? '',
|
||||||
usuarioMatricula: matricula,
|
usuarioMatricula: matricula,
|
||||||
|
dadosSolicitados: s.dadosSolicitados ?? null,
|
||||||
|
observacoes: s.observacoes ?? null,
|
||||||
|
resposta: s.resposta ?? null,
|
||||||
|
arquivoResposta: arquivoRespostaUrl,
|
||||||
criadoEm: s.criadoEm,
|
criadoEm: s.criadoEm,
|
||||||
prazoResposta: s.prazoResposta,
|
prazoResposta: s.prazoResposta,
|
||||||
respondidoEm: s.respondidoEm ?? null,
|
respondidoEm: s.respondidoEm ?? null,
|
||||||
@@ -520,6 +541,10 @@ export const listarSolicitacoes = query({
|
|||||||
usuarioNome: 'Erro ao carregar',
|
usuarioNome: 'Erro ao carregar',
|
||||||
usuarioEmail: '',
|
usuarioEmail: '',
|
||||||
usuarioMatricula: null,
|
usuarioMatricula: null,
|
||||||
|
dadosSolicitados: s.dadosSolicitados ?? null,
|
||||||
|
observacoes: s.observacoes ?? null,
|
||||||
|
resposta: s.resposta ?? null,
|
||||||
|
arquivoResposta: null,
|
||||||
criadoEm: s.criadoEm,
|
criadoEm: s.criadoEm,
|
||||||
prazoResposta: s.prazoResposta,
|
prazoResposta: s.prazoResposta,
|
||||||
respondidoEm: s.respondidoEm ?? null,
|
respondidoEm: s.respondidoEm ?? null,
|
||||||
@@ -583,6 +608,105 @@ export const responderSolicitacao = mutation({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancelar solicitação LGPD (apenas pelo próprio usuário)
|
||||||
|
*/
|
||||||
|
export const cancelarSolicitacao = mutation({
|
||||||
|
args: {
|
||||||
|
solicitacaoId: v.id('solicitacoesLGPD')
|
||||||
|
},
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se a solicitação pertence ao usuário
|
||||||
|
if (solicitacao.usuarioId !== usuario._id) {
|
||||||
|
throw new Error('Você não tem permissão para cancelar esta solicitação');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Só pode cancelar se estiver pendente ou em análise
|
||||||
|
if (solicitacao.status !== 'pendente' && solicitacao.status !== 'em_analise') {
|
||||||
|
throw new Error('Só é possível cancelar solicitações pendentes ou em análise');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atualizar status para cancelada
|
||||||
|
await ctx.db.patch(args.solicitacaoId, {
|
||||||
|
status: 'cancelada'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log de atividade
|
||||||
|
await registrarAtividade(
|
||||||
|
ctx,
|
||||||
|
usuario._id,
|
||||||
|
'cancelar_solicitacao_lgpd',
|
||||||
|
'solicitacoesLGPD',
|
||||||
|
JSON.stringify({ solicitacaoId: args.solicitacaoId }),
|
||||||
|
args.solicitacaoId.toString()
|
||||||
|
);
|
||||||
|
|
||||||
|
return { sucesso: true };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Excluir solicitação LGPD (apenas pelo próprio usuário e apenas se cancelada ou pendente)
|
||||||
|
*/
|
||||||
|
export const excluirSolicitacao = mutation({
|
||||||
|
args: {
|
||||||
|
solicitacaoId: v.id('solicitacoesLGPD')
|
||||||
|
},
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se a solicitação pertence ao usuário
|
||||||
|
if (solicitacao.usuarioId !== usuario._id) {
|
||||||
|
throw new Error('Você não tem permissão para excluir esta solicitação');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Só pode excluir se estiver pendente ou cancelada
|
||||||
|
if (solicitacao.status !== 'pendente' && solicitacao.status !== 'cancelada') {
|
||||||
|
throw new Error('Só é possível excluir solicitações pendentes ou canceladas');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Excluir arquivo de resposta se existir
|
||||||
|
if (solicitacao.arquivoResposta) {
|
||||||
|
await ctx.storage.delete(solicitacao.arquivoResposta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Excluir a solicitação
|
||||||
|
await ctx.db.delete(args.solicitacaoId);
|
||||||
|
|
||||||
|
// Log de atividade
|
||||||
|
await registrarAtividade(
|
||||||
|
ctx,
|
||||||
|
usuario._id,
|
||||||
|
'excluir_solicitacao_lgpd',
|
||||||
|
'solicitacoesLGPD',
|
||||||
|
JSON.stringify({ solicitacaoId: args.solicitacaoId }),
|
||||||
|
args.solicitacaoId.toString()
|
||||||
|
);
|
||||||
|
|
||||||
|
return { sucesso: true };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exportar dados do usuário (portabilidade)
|
* Exportar dados do usuário (portabilidade)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ export const lgpdTables = {
|
|||||||
v.literal('pendente'),
|
v.literal('pendente'),
|
||||||
v.literal('em_analise'),
|
v.literal('em_analise'),
|
||||||
v.literal('concluida'),
|
v.literal('concluida'),
|
||||||
v.literal('rejeitada')
|
v.literal('rejeitada'),
|
||||||
|
v.literal('cancelada')
|
||||||
),
|
),
|
||||||
dadosSolicitados: v.optional(v.string()), // JSON com detalhes da solicitação
|
dadosSolicitados: v.optional(v.string()), // JSON com detalhes da solicitação
|
||||||
resposta: v.optional(v.string()), // Resposta da solicitação
|
resposta: v.optional(v.string()), // Resposta da solicitação
|
||||||
|
|||||||
Reference in New Issue
Block a user