feat: enhance DocumentacaoCard with visualizer button and improve PDF generation with structured content and metadata handling

This commit is contained in:
2025-12-07 11:49:20 -03:00
parent 426e358d86
commit 10a729baed
9 changed files with 1987 additions and 356 deletions

View File

@@ -2,7 +2,7 @@
import { resolve } from '$app/paths';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { FileText, Calendar, Tag, CheckCircle2, Circle } from 'lucide-svelte';
import { FileText, Calendar, Tag, CheckCircle2, Circle, Eye } from 'lucide-svelte';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
type TipoDocumento =
@@ -35,9 +35,10 @@
documento: Documento;
selecionado?: boolean;
onToggleSelecao?: () => void;
onVisualizar?: () => void;
}
let { documento, selecionado = false, onToggleSelecao }: Props = $props();
let { documento, selecionado = false, onToggleSelecao, onVisualizar }: Props = $props();
const tipoLabels: Record<string, string> = {
query: 'Query',
@@ -63,23 +64,25 @@
</script>
<article
class="group relative flex cursor-pointer flex-col gap-4 overflow-hidden rounded-2xl border border-base-300 bg-base-100 p-6 shadow-lg transition-all duration-300 hover:scale-[1.02] hover:shadow-xl"
class="group relative flex flex-col gap-4 overflow-hidden rounded-2xl border border-base-300 bg-base-100 p-6 shadow-lg transition-all duration-300 hover:scale-[1.02] hover:shadow-xl {onToggleSelecao ? '' : 'cursor-pointer'}"
onclick={() => {
if (onToggleSelecao) {
onToggleSelecao();
} else {
// Se não houver seleção habilitada, redireciona para a página do documento
if (!onToggleSelecao) {
window.location.href = resolve(`/ti/documentacao/${documento._id}`);
}
// Se houver seleção, não faz nada no clique do card (apenas o checkbox seleciona)
}}
>
<!-- Checkbox de seleção -->
{#if onToggleSelecao}
<button
class="absolute right-4 top-4 z-10"
class="absolute right-4 top-4 z-20 cursor-pointer"
onclick={(e) => {
e.stopPropagation();
e.preventDefault();
onToggleSelecao();
}}
type="button"
>
{#if selecionado}
<CheckCircle2 class="text-primary h-6 w-6" />
@@ -134,12 +137,31 @@
<div class="flex items-center gap-2 text-xs text-base-content/50">
<Calendar class="h-4 w-4" />
<span>
{format(new Date(documento.atualizadoEm), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}
{documento.atualizadoEm && !isNaN(new Date(documento.atualizadoEm).getTime())
? format(new Date(documento.atualizadoEm), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })
: 'Data não disponível'}
</span>
</div>
{#if documento.visualizacoes !== undefined}
<span class="text-base-content/50 text-xs">{documento.visualizacoes} visualizações</span>
{/if}
<div class="flex items-center gap-3">
{#if documento.visualizacoes !== undefined}
<span class="text-base-content/50 text-xs">{documento.visualizacoes} visualizações</span>
{/if}
{#if onVisualizar}
<button
class="btn btn-ghost btn-xs gap-1"
onclick={(e) => {
e.stopPropagation();
e.preventDefault();
onVisualizar();
}}
title="Visualizar documento"
type="button"
>
<Eye class="h-4 w-4" />
Visualizar
</button>
{/if}
</div>
</div>
</article>

View File

@@ -0,0 +1,322 @@
<script lang="ts">
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { marked } from 'marked';
import { X, BookOpen, FileText, Calendar, User, Tag, Eye } from 'lucide-svelte';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
interface Props {
documentoId: Id<'documentacao'> | null;
onClose: () => void;
}
let { documentoId, onClose }: Props = $props();
const documentoQuery = useQuery(
api.documentacao.obterDocumento,
documentoId ? { documentoId } : 'skip'
);
// Gerar índice a partir do conteúdo Markdown
const indice = $derived(() => {
if (!documentoQuery?.conteudo) return [];
const linhas = documentoQuery.conteudo.split('\n');
const indices: Array<{ nivel: number; titulo: string; id: string }> = [];
linhas.forEach((linha) => {
const match = linha.match(/^(#{1,3})\s+(.+)$/);
if (match) {
const nivel = match[1].length;
const titulo = match[2].trim();
const id = titulo
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
indices.push({ nivel, titulo, id });
}
});
return indices;
});
function scrollParaSecao(id: string) {
const elemento = document.getElementById(id);
if (elemento) {
elemento.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
// Processar HTML para adicionar IDs aos títulos
const conteudoHtml = $derived(() => {
if (!documentoQuery) return '';
// Se já existe HTML renderizado, usar ele
if (documentoQuery.conteudoHtml && documentoQuery.conteudoHtml.trim().length > 0) {
let html = documentoQuery.conteudoHtml;
// Adicionar IDs aos títulos
html = html.replace(/<h([1-3])>(.+?)<\/h[1-3]>/g, (match, nivel, titulo) => {
const id = titulo
.replace(/<[^>]+>/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
return `<h${nivel} id="${id}">${titulo}</h${nivel}>`;
});
return html;
}
// Se não, converter Markdown para HTML
if (documentoQuery.conteudo && documentoQuery.conteudo.trim().length > 0) {
let html = marked.parse(documentoQuery.conteudo);
// Adicionar IDs aos títulos
html = html.replace(/<h([1-3])>(.+?)<\/h[1-3]>/g, (match, nivel, titulo) => {
const id = titulo
.replace(/<[^>]+>/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
return `<h${nivel} id="${id}">${titulo}</h${nivel}>`;
});
return html;
}
return '';
});
</script>
{#if documentoId}
{#if documentoQuery === undefined}
<div class="modal modal-open">
<div class="modal-box max-w-6xl">
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg"></span>
</div>
</div>
<div class="modal-backdrop" onclick={onClose}></div>
</div>
{:else if documentoQuery}
{@const doc = documentoQuery}
{#if doc}
<div class="modal modal-open">
<div class="modal-box max-w-6xl max-h-[90vh] flex flex-col">
<!-- Header -->
<div class="mb-4 flex items-center justify-between border-b border-base-300 pb-4">
<div class="flex items-center gap-3">
<BookOpen class="text-primary h-6 w-6" />
<h2 class="text-2xl font-bold">{doc.titulo || 'Documento'}</h2>
</div>
<button class="btn btn-circle btn-ghost btn-sm" onclick={onClose}>
<X class="h-5 w-5" />
</button>
</div>
<!-- Conteúdo com scroll -->
<div class="flex flex-1 gap-6 overflow-hidden">
<!-- Índice lateral -->
{#if indice().length > 0}
<aside class="w-64 shrink-0 overflow-y-auto border-r border-base-300 pr-4">
<div class="sticky top-0">
<h3 class="text-base-content mb-4 text-sm font-semibold uppercase">Índice</h3>
<nav class="space-y-1">
{#each indice() as item}
<button
class="text-base-content/70 hover:text-primary block w-full text-left text-sm transition-colors {item.nivel === 1
? 'font-semibold'
: item.nivel === 2
? 'ml-4 font-medium'
: 'ml-8'}"
onclick={() => scrollParaSecao(item.id)}
>
{item.titulo}
</button>
{/each}
</nav>
</div>
</aside>
{/if}
<!-- Conteúdo principal -->
<div class="flex-1 overflow-y-auto">
<!-- Metadados do Documento (carregados do card) -->
<div class="bg-base-200 mb-6 rounded-lg p-4">
<div class="flex flex-wrap items-center gap-4 text-sm">
<div class="flex items-center gap-2">
<FileText class="text-base-content/50 h-4 w-4" />
<span class="badge badge-primary">{doc.tipo}</span>
</div>
<div class="flex items-center gap-2">
<Calendar class="text-base-content/50 h-4 w-4" />
<span class="text-base-content/70 font-medium">
{doc.atualizadoEm && !isNaN(new Date(doc.atualizadoEm).getTime())
? format(new Date(doc.atualizadoEm), "dd/MM/yyyy 'às' HH:mm", {
locale: ptBR
})
: 'Data não disponível'}
</span>
</div>
<div class="flex items-center gap-2">
<Eye class="text-base-content/50 h-4 w-4" />
<span class="text-base-content/70 font-medium">
{doc.visualizacoes || 0} {doc.visualizacoes === 1 ? 'visualização' : 'visualizações'}
</span>
</div>
{#if doc.criadoPorUsuario}
<div class="flex items-center gap-2">
<User class="text-base-content/50 h-4 w-4" />
<span class="text-base-content/70">{doc.criadoPorUsuario.nome}</span>
</div>
{/if}
</div>
</div>
<!-- Tags -->
{#if doc.tags && doc.tags.length > 0}
<div class="mb-6 flex flex-wrap items-center gap-2">
<Tag class="text-base-content/50 h-5 w-5" />
{#each doc.tags as tag}
<span class="badge badge-outline badge-sm">{tag}</span>
{/each}
</div>
{/if}
<!-- Conteúdo do Documento (carregado do card selecionado) -->
<div class="mb-8">
<h3 class="text-base-content mb-4 text-xl font-bold border-b border-base-300 pb-2">
Conteúdo do Documento
</h3>
<!-- Renderizar conteúdo quando disponível -->
{#if doc.conteudo && doc.conteudo.trim().length > 0}
<!-- Exibir conteúdo Markdown convertido para HTML -->
<div class="prose prose-slate prose-lg max-w-none">
{@html marked.parse(doc.conteudo)}
</div>
{:else if doc.conteudoHtml && doc.conteudoHtml.trim().length > 0}
<!-- Exibir conteúdo HTML renderizado -->
<div class="prose prose-slate prose-lg max-w-none">
{@html doc.conteudoHtml}
</div>
{:else}
<!-- Mensagem quando não há conteúdo -->
<div class="bg-base-200 rounded-lg p-6 text-center">
<p class="text-base-content/70 mb-2">Conteúdo não disponível para este documento.</p>
{#if doc.arquivoOrigem}
<p class="text-base-content/50 text-sm">
Arquivo: <span class="font-mono">{doc.arquivoOrigem}</span>
</p>
{/if}
{#if doc.funcaoOrigem}
<p class="text-base-content/50 text-sm mt-1">
Função: <span class="font-mono">{doc.funcaoOrigem}</span>
</p>
{/if}
</div>
{/if}
</div>
<!-- Informações adicionais -->
{#if doc.arquivoOrigem}
<div class="bg-base-200 mt-8 rounded-lg p-6">
<h3 class="text-base-content mb-4 flex items-center gap-2 text-lg font-semibold">
<FileText class="h-5 w-5" />
Informações do Arquivo
</h3>
<div class="space-y-2">
<div>
<span class="text-base-content/70 font-medium">Arquivo Origem:</span>
<span class="text-base-content ml-2 font-mono text-sm">{doc.arquivoOrigem}</span>
</div>
{#if doc.funcaoOrigem}
<div>
<span class="text-base-content/70 font-medium">Função Origem:</span>
<span class="text-base-content ml-2 font-mono text-sm">{doc.funcaoOrigem}</span>
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
<!-- Footer -->
<div class="mt-4 flex justify-end border-t border-base-300 pt-4">
<button class="btn btn-ghost" onclick={onClose}>Fechar</button>
</div>
</div>
<div class="modal-backdrop" onclick={onClose}></div>
</div>
{/if}
{:else}
<div class="modal modal-open">
<div class="modal-box max-w-6xl">
<div class="bg-base-200 rounded-2xl border border-base-300 p-12 text-center">
<h3 class="text-base-content mb-2 text-xl font-semibold">Documento não encontrado</h3>
<p class="text-base-content/70">O documento solicitado não existe ou foi removido.</p>
</div>
</div>
<div class="modal-backdrop" onclick={onClose}></div>
</div>
{/if}
{/if}
<style>
:global(.prose) {
color: hsl(var(--bc));
line-height: 1.8;
}
:global(.prose h1) {
color: hsl(var(--p));
font-size: 2.5em;
font-weight: 700;
margin-top: 1.5em;
margin-bottom: 0.5em;
border-bottom: 2px solid hsl(var(--b3));
padding-bottom: 0.5em;
scroll-margin-top: 1rem;
}
:global(.prose h2) {
color: hsl(var(--p));
font-size: 2em;
font-weight: 600;
margin-top: 1.5em;
margin-bottom: 0.75em;
scroll-margin-top: 1rem;
}
:global(.prose h3) {
color: hsl(var(--p));
font-size: 1.5em;
font-weight: 600;
margin-top: 1.25em;
margin-bottom: 0.5em;
scroll-margin-top: 1rem;
}
:global(.prose code) {
background-color: hsl(var(--b2));
padding: 0.2em 0.5em;
border-radius: 0.375rem;
font-size: 0.9em;
font-family: 'Courier New', monospace;
color: hsl(var(--p));
}
:global(.prose pre) {
background-color: hsl(var(--b2));
padding: 1.25rem;
border-radius: 0.5rem;
overflow-x: auto;
border-left: 4px solid hsl(var(--p));
margin: 1.5em 0;
}
</style>

View File

@@ -3,6 +3,7 @@
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import { marked } from 'marked';
import { X, Download, Loader2 } from 'lucide-svelte';
@@ -31,101 +32,224 @@
}
const doc = new jsPDF();
let yPos = 20;
const pageHeight = doc.internal.pageSize.getHeight();
const pageWidth = doc.internal.pageSize.getWidth();
const margin = 20;
const primaryColor = [102, 126, 234];
const secondaryColor = [128, 128, 128];
// Título
doc.setFontSize(20);
doc.setTextColor(102, 126, 234);
doc.text('Biblioteca de Documentação SGSE', margin, yPos);
yPos += 15;
// Cabeçalho
doc.setFillColor(...primaryColor);
doc.rect(0, 0, pageWidth, 40, 'F');
doc.setTextColor(255, 255, 255);
doc.setFontSize(22);
doc.setFont('helvetica', 'bold');
doc.text('Biblioteca de Documentação SGSE', pageWidth / 2, 25, { align: 'center' });
doc.setFontSize(12);
let yPos = 50;
// Informações de geração
doc.setTextColor(0, 0, 0);
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.text(`Gerado em: ${new Date().toLocaleString('pt-BR')}`, margin, yPos);
yPos += 10;
yPos += 6;
doc.text(`Total de documentos: ${documentos.length}`, margin, yPos);
yPos += 15;
// Índice
// Índice com tabela
doc.setFontSize(16);
doc.setTextColor(102, 126, 234);
doc.setTextColor(...primaryColor);
doc.setFont('helvetica', 'bold');
doc.text('Índice', margin, yPos);
yPos += 10;
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
documentos.forEach((documento, index) => {
if (yPos > pageHeight - 30) {
doc.addPage();
yPos = margin;
const indiceData = documentos.map((doc, index) => [
(index + 1).toString(),
doc.titulo,
doc.tipo,
doc.versao
]);
autoTable(doc, {
startY: yPos,
head: [['#', 'Título', 'Tipo', 'Versão']],
body: indiceData,
theme: 'striped',
headStyles: {
fillColor: primaryColor,
textColor: [255, 255, 255],
fontStyle: 'bold'
},
styles: {
fontSize: 9,
cellPadding: 3
},
columnStyles: {
0: { cellWidth: 15 },
1: { cellWidth: 'auto' },
2: { cellWidth: 40 },
3: { cellWidth: 30 }
}
doc.text(`${index + 1}. ${documento.titulo}`, margin + 5, yPos);
yPos += 7;
});
yPos += 10;
yPos = (doc as any).lastAutoTable.finalY + 15;
// Documentos
for (let i = 0; i < documentos.length; i++) {
const documento = documentos[i];
// Nova página para cada documento
if (i > 0 || yPos > pageHeight - 50) {
// Nova página para cada documento (exceto o primeiro)
if (i > 0) {
doc.addPage();
yPos = margin;
} else if (yPos > pageHeight - 50) {
doc.addPage();
yPos = margin;
}
// Título do documento
doc.setFontSize(18);
doc.setTextColor(102, 126, 234);
doc.text(documento.titulo, margin, yPos, { maxWidth: 170 });
yPos += 10;
// Cabeçalho do documento
doc.setFillColor(...primaryColor);
doc.rect(0, yPos - 10, pageWidth, 15, 'F');
doc.setTextColor(255, 255, 255);
doc.setFontSize(16);
doc.setFont('helvetica', 'bold');
doc.text(`${i + 1}. ${documento.titulo}`, margin, yPos + 3, { maxWidth: pageWidth - 2 * margin });
yPos += 20;
// Metadados
doc.setFontSize(10);
doc.setTextColor(128, 128, 128);
doc.text(`Tipo: ${documento.tipo} | Versão: ${documento.versao}`, margin, yPos);
yPos += 8;
// Metadados em tabela
autoTable(doc, {
startY: yPos,
body: [
['Tipo', documento.tipo],
['Versão', documento.versao],
['Arquivo Origem', documento.arquivoOrigem || 'N/A']
],
theme: 'plain',
styles: {
fontSize: 9,
cellPadding: 4
},
columnStyles: {
0: { fontStyle: 'bold', cellWidth: 50, fillColor: [240, 240, 240] },
1: { cellWidth: 'auto' }
}
});
yPos = (doc as any).lastAutoTable.finalY + 10;
// Conteúdo
doc.setFontSize(11);
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
doc.setFont('helvetica', 'normal');
// Converter Markdown para texto simples (remover formatação)
// Processar Markdown de forma mais inteligente
let conteudoTexto = documento.conteudo;
// Remover markdown básico
conteudoTexto = conteudoTexto.replace(/#{1,6}\s+/g, ''); // Headers
conteudoTexto = conteudoTexto.replace(/\*\*(.*?)\*\*/g, '$1'); // Bold
conteudoTexto = conteudoTexto.replace(/\*(.*?)\*/g, '$1'); // Italic
conteudoTexto = conteudoTexto.replace(/`(.*?)`/g, '$1'); // Code
conteudoTexto = conteudoTexto.replace(/\[(.*?)\]\(.*?\)/g, '$1'); // Links
// Dividir em linhas e adicionar ao PDF
const linhas = doc.splitTextToSize(conteudoTexto, 170);
for (const linha of linhas) {
if (yPos > pageHeight - 20) {
doc.addPage();
yPos = margin;
// Processar seções
const secoes = conteudoTexto.split(/\n##\s+/);
for (let j = 0; j < secoes.length; j++) {
let secao = secoes[j];
if (j === 0) {
// Primeira seção (antes do primeiro ##)
secao = secao.replace(/^#\s+.*?\n\n/, ''); // Remove título principal
}
doc.text(linha, margin, yPos);
yPos += 6;
// Detectar título da seção
const tituloMatch = secao.match(/^(.+?)\n/);
if (tituloMatch && j > 0) {
// Adicionar título da seção
if (yPos > pageHeight - 30) {
doc.addPage();
yPos = margin;
}
doc.setFontSize(12);
doc.setTextColor(...primaryColor);
doc.setFont('helvetica', 'bold');
doc.text(tituloMatch[1], margin, yPos);
yPos += 8;
secao = secao.substring(tituloMatch[0].length);
}
// Processar conteúdo da seção
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
doc.setFont('helvetica', 'normal');
// Remover markdown básico
secao = secao.replace(/\*\*(.*?)\*\*/g, '$1'); // Bold
secao = secao.replace(/\*(.*?)\*/g, '$1'); // Italic
secao = secao.replace(/`(.*?)`/g, '$1'); // Code inline
secao = secao.replace(/\[(.*?)\]\(.*?\)/g, '$1'); // Links
// Processar listas
const linhas = secao.split('\n');
for (const linha of linhas) {
if (yPos > pageHeight - 20) {
doc.addPage();
yPos = margin;
}
if (linha.trim().startsWith('-') || linha.trim().startsWith('*')) {
// Item de lista
const texto = linha.replace(/^[\s\-*]+/, '• ');
const linhasTexto = doc.splitTextToSize(texto, pageWidth - 2 * margin - 10);
doc.text(linhasTexto[0], margin + 5, yPos);
yPos += 5;
for (let k = 1; k < linhasTexto.length; k++) {
if (yPos > pageHeight - 20) {
doc.addPage();
yPos = margin;
}
doc.text(linhasTexto[k], margin + 10, yPos);
yPos += 5;
}
} else if (linha.trim().startsWith('###')) {
// Subtítulo
const subtitulo = linha.replace(/^###\s+/, '');
doc.setFontSize(11);
doc.setTextColor(...primaryColor);
doc.setFont('helvetica', 'bold');
doc.text(subtitulo, margin + 5, yPos);
yPos += 7;
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
doc.setFont('helvetica', 'normal');
} else if (linha.trim()) {
// Texto normal
const linhasTexto = doc.splitTextToSize(linha.trim(), pageWidth - 2 * margin);
for (const linhaTexto of linhasTexto) {
if (yPos > pageHeight - 20) {
doc.addPage();
yPos = margin;
}
doc.text(linhaTexto, margin, yPos);
yPos += 5;
}
} else {
// Linha vazia
yPos += 3;
}
}
yPos += 5;
}
yPos += 10;
}
// Footer em todas as páginas
const pageCount = doc.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i);
doc.setFillColor(240, 240, 240);
doc.rect(0, pageHeight - 15, pageWidth, 15, 'F');
doc.setFontSize(8);
doc.setTextColor(128, 128, 128);
doc.setTextColor(...secondaryColor);
doc.setFont('helvetica', 'normal');
doc.text(
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
doc.internal.pageSize.getWidth() / 2,
doc.internal.pageSize.getHeight() - 10,
pageWidth / 2,
pageHeight - 7,
{ align: 'center' }
);
}