feat: add report printing and deletion functionality

- Implemented a new feature to print security reports with structured JSON observations, enhancing report accessibility.
- Added a deletion function for reports, allowing users to remove reports with confirmation prompts.
- Updated the CybersecurityWizcard component to include action buttons for printing and deleting reports, improving user interaction.
- Enhanced the backend with a mutation to handle report deletions, ensuring proper cleanup of associated artifacts.
This commit is contained in:
2025-11-16 08:24:58 -03:00
parent 70d405d98d
commit 60e0bfa69e
3 changed files with 142 additions and 2 deletions

View File

@@ -504,6 +504,87 @@
const data = new Date(valor); const data = new Date(valor);
return Number.isNaN(data.getTime()) ? Date.now() : data.getTime(); return Number.isNaN(data.getTime()) ? Date.now() : data.getTime();
} }
// Imprimir conteúdo do relatório (usa campo observações JSON estruturado)
function imprimirRelatorio(r: {
_id: Id<'reportRequests'>;
status: 'pendente' | 'processando' | 'concluido' | 'falhou';
criadoEm: number;
concluidoEm?: number;
observacoes?: string;
}) {
if (typeof window === 'undefined') return;
const win = window.open('', '_blank', 'noopener,noreferrer,width=900,height=700');
if (!win) return;
let conteudo: string;
try {
const data = r.observacoes ? JSON.parse(r.observacoes) as any : null;
const total = data?.total ?? '—';
const porSeveridade = data?.porSeveridade ?? {};
const porAtaque = data?.porAtaque ?? {};
const linhasSev = Object.entries(porSeveridade)
.map(([k, v]) => `<tr><td>${k}</td><td style="text-align:right">${v as number}</td></tr>`)
.join('');
const linhasAtk = Object.entries(porAtaque)
.map(([k, v]) => `<tr><td>${k}</td><td style="text-align:right">${v as number}</td></tr>`)
.join('');
const criadoStr = new Date(r.criadoEm).toLocaleString('pt-BR', { hour12: false });
const concluidoStr = r.concluidoEm ? new Date(r.concluidoEm).toLocaleString('pt-BR', { hour12: false }) : '—';
const agoraStr = new Date().toLocaleString('pt-BR', { hour12: false });
conteudo =
'<!doctype html>' +
'<html><head><meta charset="utf-8"/><title>Relatório de Segurança</title>' +
'<style>' +
"body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif; margin: 24px; color: #111;}" +
'h1 { margin: 0 0 4px 0; font-size: 20px;}' +
'h2 { margin: 16px 0 8px; font-size: 16px;}' +
'.meta { color: #666; font-size: 12px; margin-bottom: 16px;}' +
'table { border-collapse: collapse; width: 100%; margin-top: 8px;}' +
'th, td { border: 1px solid #ddd; padding: 8px; font-size: 12px;}' +
'th { background: #f5f5f5; text-align: left;}' +
'.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }' +
'@media print { .no-print { display: none; } }' +
'.footer { margin-top: 24px; font-size: 11px; color: #666;}' +
'</style></head><body>' +
'<div class="no-print" style="text-align:right;margin-bottom:12px"><button onclick="window.print()">Imprimir</button></div>' +
'<h1>Relatório de Segurança</h1>' +
'<div class="meta">Status: <strong>' + r.status + '</strong> · Criado: ' + criadoStr + ' · Concluído: ' + concluidoStr + '</div>' +
'<h2>Resumo</h2>' +
'<p>Total de eventos no período: <strong>' + total + '</strong></p>' +
'<div class="grid"><div><h2>Por Severidade</h2><table><thead><tr><th>Severidade</th><th style="text-align:right">Qtde</th></tr></thead><tbody>' +
(linhasSev || '<tr><td colspan="2">—</td></tr>') +
'</tbody></table></div><div><h2>Por Tipo de Ataque</h2><table><thead><tr><th>Tipo</th><th style="text-align:right">Qtde</th></tr></thead><tbody>' +
(linhasAtk || '<tr><td colspan="2">—</td></tr>') +
'</tbody></table></div></div>' +
'<div class="footer">Gerado por SGSE · ' + agoraStr + '</div>' +
'</body></html>';
} catch {
const obs = (r.observacoes ?? '').replace(/</g, '&lt;');
// Evitar literal de fechamento de script dentro deste bloco
const scriptOpen = '<scr' + 'ipt>';
const scriptClose = '</scr' + 'ipt>';
conteudo =
'<!doctype html><meta charset="utf-8"/>' +
'<title>Relatório</title>' +
'<pre>' + obs + '</pre>' +
scriptOpen + 'window.print()' + scriptClose;
}
win.document.open();
win.document.write(conteudo);
win.document.close();
}
async function excluirRelatorio(relatorioId: Id<'reportRequests'>) {
if (!confirm('Excluir este relatório? Esta ação não pode ser desfeita.')) return;
try {
const resp = await client.mutation(api.security.deletarRelatorio, { relatorioId });
if (resp?.success) {
feedback = { tipo: 'success', mensagem: 'Relatório excluído.' };
} else {
feedback = { tipo: 'error', mensagem: 'Não foi possível excluir o relatório.' };
}
} catch (erro: unknown) {
feedback = { tipo: 'error', mensagem: mensagemErro(erro) };
}
}
async function gerarRelatorioAvancado(e: SubmitEvent) { async function gerarRelatorioAvancado(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
@@ -1396,6 +1477,7 @@
<th>Criado</th> <th>Criado</th>
<th>Concluído</th> <th>Concluído</th>
<th>Observações</th> <th>Observações</th>
<th class="w-40">Ações</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -1407,6 +1489,26 @@
<td>{new Date(r.criadoEm).toLocaleString('pt-BR', { hour12: false })}</td> <td>{new Date(r.criadoEm).toLocaleString('pt-BR', { hour12: false })}</td>
<td>{r.concluidoEm ? new Date(r.concluidoEm).toLocaleString('pt-BR', { hour12: false }) : '-'}</td> <td>{r.concluidoEm ? new Date(r.concluidoEm).toLocaleString('pt-BR', { hour12: false }) : '-'}</td>
<td class="max-w-xs truncate">{r.observacoes ?? '-'}</td> <td class="max-w-xs truncate">{r.observacoes ?? '-'}</td>
<td>
<div class="flex gap-2">
<button
type="button"
class="btn btn-xs btn-outline"
disabled={r.status !== 'concluido'}
title={r.status !== 'concluido' ? 'Aguarde concluir' : 'Imprimir'}
onclick={() => imprimirRelatorio(r)}
>
Imprimir
</button>
<button
type="button"
class="btn btn-xs btn-error"
onclick={() => excluirRelatorio(r._id)}
>
Excluir
</button>
</div>
</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>

View File

@@ -1,6 +1,13 @@
<script lang="ts"> <script lang="ts">
import CybersecurityWizcard from '$lib/components/ti/CybersecurityWizcard.svelte';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { browser } from '$app/environment';
import { onMount } from 'svelte';
// Usar tipo amplo para evitar conflitos de tipagem do import dinâmico no build
let Comp: any = null;
onMount(async () => {
const mod = await import('$lib/components/ti/CybersecurityWizcard.svelte');
Comp = mod.default;
});
</script> </script>
<svelte:head> <svelte:head>
@@ -23,7 +30,13 @@
<a href={resolve('/ti')} class="btn btn-outline btn-primary">Voltar para TI</a> <a href={resolve('/ti')} class="btn btn-outline btn-primary">Voltar para TI</a>
</header> </header>
<CybersecurityWizcard /> {#if browser && Comp}
<svelte:component this={Comp} />
{:else}
<div class="alert">
<span>Carregando módulo de cibersegurança…</span>
</div>
{/if}
</section> </section>

View File

@@ -1301,6 +1301,31 @@ export const healthStatus = query({
}; };
} }
}); });
/**
* Excluir relatório gerado (e artefato, se houver)
*/
export const deletarRelatorio = mutation({
args: {
relatorioId: v.id('reportRequests')
},
returns: v.object({ success: v.boolean() }),
handler: async (ctx, args) => {
const doc = await ctx.db.get(args.relatorioId);
if (!doc) {
return { success: false };
}
// Remover arquivo em storage se existir
if (doc.resultadoId) {
try {
await ctx.storage.delete(doc.resultadoId);
} catch {
// Ignorar falha ao excluir artefato de storage
}
}
await ctx.db.delete(args.relatorioId);
return { success: true };
}
});
export const processarRelatorioSegurancaInternal = internalMutation({ export const processarRelatorioSegurancaInternal = internalMutation({
args: { args: {
relatorioId: v.id('reportRequests') relatorioId: v.id('reportRequests')