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 { resolve } from '$app/paths';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale'; 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'; import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
type TipoDocumento = type TipoDocumento =
@@ -35,9 +35,10 @@
documento: Documento; documento: Documento;
selecionado?: boolean; selecionado?: boolean;
onToggleSelecao?: () => void; onToggleSelecao?: () => void;
onVisualizar?: () => void;
} }
let { documento, selecionado = false, onToggleSelecao }: Props = $props(); let { documento, selecionado = false, onToggleSelecao, onVisualizar }: Props = $props();
const tipoLabels: Record<string, string> = { const tipoLabels: Record<string, string> = {
query: 'Query', query: 'Query',
@@ -63,23 +64,25 @@
</script> </script>
<article <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={() => { onclick={() => {
if (onToggleSelecao) { // Se não houver seleção habilitada, redireciona para a página do documento
onToggleSelecao(); if (!onToggleSelecao) {
} else {
window.location.href = resolve(`/ti/documentacao/${documento._id}`); 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 --> <!-- Checkbox de seleção -->
{#if onToggleSelecao} {#if onToggleSelecao}
<button <button
class="absolute right-4 top-4 z-10" class="absolute right-4 top-4 z-20 cursor-pointer"
onclick={(e) => { onclick={(e) => {
e.stopPropagation(); e.stopPropagation();
e.preventDefault();
onToggleSelecao(); onToggleSelecao();
}} }}
type="button"
> >
{#if selecionado} {#if selecionado}
<CheckCircle2 class="text-primary h-6 w-6" /> <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"> <div class="flex items-center gap-2 text-xs text-base-content/50">
<Calendar class="h-4 w-4" /> <Calendar class="h-4 w-4" />
<span> <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> </span>
</div> </div>
<div class="flex items-center gap-3">
{#if documento.visualizacoes !== undefined} {#if documento.visualizacoes !== undefined}
<span class="text-base-content/50 text-xs">{documento.visualizacoes} visualizações</span> <span class="text-base-content/50 text-xs">{documento.visualizacoes} visualizações</span>
{/if} {/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> </div>
</article> </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 { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import jsPDF from 'jspdf'; import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import { marked } from 'marked'; import { marked } from 'marked';
import { X, Download, Loader2 } from 'lucide-svelte'; import { X, Download, Loader2 } from 'lucide-svelte';
@@ -31,101 +32,224 @@
} }
const doc = new jsPDF(); const doc = new jsPDF();
let yPos = 20;
const pageHeight = doc.internal.pageSize.getHeight(); const pageHeight = doc.internal.pageSize.getHeight();
const pageWidth = doc.internal.pageSize.getWidth();
const margin = 20; const margin = 20;
const primaryColor = [102, 126, 234];
const secondaryColor = [128, 128, 128];
// Título // Cabeçalho
doc.setFontSize(20); doc.setFillColor(...primaryColor);
doc.setTextColor(102, 126, 234); doc.rect(0, 0, pageWidth, 40, 'F');
doc.text('Biblioteca de Documentação SGSE', margin, yPos); doc.setTextColor(255, 255, 255);
yPos += 15; 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.setTextColor(0, 0, 0);
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.text(`Gerado em: ${new Date().toLocaleString('pt-BR')}`, margin, yPos); doc.text(`Gerado em: ${new Date().toLocaleString('pt-BR')}`, margin, yPos);
yPos += 10; yPos += 6;
doc.text(`Total de documentos: ${documentos.length}`, margin, yPos); doc.text(`Total de documentos: ${documentos.length}`, margin, yPos);
yPos += 15; yPos += 15;
// Índice // Índice com tabela
doc.setFontSize(16); doc.setFontSize(16);
doc.setTextColor(102, 126, 234); doc.setTextColor(...primaryColor);
doc.setFont('helvetica', 'bold');
doc.text('Índice', margin, yPos); doc.text('Índice', margin, yPos);
yPos += 10; yPos += 10;
doc.setFontSize(10); const indiceData = documentos.map((doc, index) => [
doc.setTextColor(0, 0, 0); (index + 1).toString(),
documentos.forEach((documento, index) => { doc.titulo,
if (yPos > pageHeight - 30) { doc.tipo,
doc.addPage(); doc.versao
yPos = margin; ]);
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 // Documentos
for (let i = 0; i < documentos.length; i++) { for (let i = 0; i < documentos.length; i++) {
const documento = documentos[i]; const documento = documentos[i];
// Nova página para cada documento // Nova página para cada documento (exceto o primeiro)
if (i > 0 || yPos > pageHeight - 50) { if (i > 0) {
doc.addPage();
yPos = margin;
} else if (yPos > pageHeight - 50) {
doc.addPage(); doc.addPage();
yPos = margin; yPos = margin;
} }
// Título do documento // Cabeçalho do documento
doc.setFontSize(18); doc.setFillColor(...primaryColor);
doc.setTextColor(102, 126, 234); doc.rect(0, yPos - 10, pageWidth, 15, 'F');
doc.text(documento.titulo, margin, yPos, { maxWidth: 170 }); doc.setTextColor(255, 255, 255);
yPos += 10; doc.setFontSize(16);
doc.setFont('helvetica', 'bold');
doc.text(`${i + 1}. ${documento.titulo}`, margin, yPos + 3, { maxWidth: pageWidth - 2 * margin });
yPos += 20;
// Metadados // Metadados em tabela
doc.setFontSize(10); autoTable(doc, {
doc.setTextColor(128, 128, 128); startY: yPos,
doc.text(`Tipo: ${documento.tipo} | Versão: ${documento.versao}`, margin, yPos); body: [
yPos += 8; ['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 // Conteúdo
doc.setFontSize(11); doc.setFontSize(10);
doc.setTextColor(0, 0, 0); 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; 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 // Processar seções
const linhas = doc.splitTextToSize(conteudoTexto, 170); 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
}
// 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) { for (const linha of linhas) {
if (yPos > pageHeight - 20) { if (yPos > pageHeight - 20) {
doc.addPage(); doc.addPage();
yPos = margin; yPos = margin;
} }
doc.text(linha, margin, yPos);
yPos += 6; 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 += 10; yPos += 5;
}
} }
// Footer em todas as páginas // Footer em todas as páginas
const pageCount = doc.getNumberOfPages(); const pageCount = doc.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) { for (let i = 1; i <= pageCount; i++) {
doc.setPage(i); doc.setPage(i);
doc.setFillColor(240, 240, 240);
doc.rect(0, pageHeight - 15, pageWidth, 15, 'F');
doc.setFontSize(8); doc.setFontSize(8);
doc.setTextColor(128, 128, 128); doc.setTextColor(...secondaryColor);
doc.setFont('helvetica', 'normal');
doc.text( doc.text(
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`, `SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
doc.internal.pageSize.getWidth() / 2, pageWidth / 2,
doc.internal.pageSize.getHeight() - 10, pageHeight - 7,
{ align: 'center' } { align: 'center' }
); );
} }

View File

@@ -1,28 +1,15 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { useQuery, useConvexClient } from 'convex-svelte'; import { useQuery } 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 type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte'; import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
import { format } from 'date-fns'; import { Filter, Settings, Download, BookOpen } from 'lucide-svelte';
import { ptBR } from 'date-fns/locale';
import {
FileText,
Search,
Filter,
Settings,
Download,
BookOpen,
Tag,
Folder,
ChevronRight
} from 'lucide-svelte';
import DocumentacaoCard from '$lib/components/documentacao/DocumentacaoCard.svelte'; import DocumentacaoCard from '$lib/components/documentacao/DocumentacaoCard.svelte';
import DocumentacaoSearch from '$lib/components/documentacao/DocumentacaoSearch.svelte'; import DocumentacaoSearch from '$lib/components/documentacao/DocumentacaoSearch.svelte';
import DocumentacaoSidebar from '$lib/components/documentacao/DocumentacaoSidebar.svelte'; import DocumentacaoSidebar from '$lib/components/documentacao/DocumentacaoSidebar.svelte';
import PdfGenerator from '$lib/components/documentacao/PdfGenerator.svelte'; import PdfGenerator from '$lib/components/documentacao/PdfGenerator.svelte';
import DocumentacaoModal from '$lib/components/documentacao/DocumentacaoModal.svelte';
const client = useConvexClient();
// Estados // Estados
let busca = $state(''); let busca = $state('');
@@ -33,41 +20,49 @@
let tagsSelecionadas = $state<string[]>([]); let tagsSelecionadas = $state<string[]>([]);
let mostrarFiltros = $state(false); let mostrarFiltros = $state(false);
let mostrarPdfGenerator = $state(false); let mostrarPdfGenerator = $state(false);
let documentoModalId = $state<Id<'documentacao'> | null>(null);
let documentosSelecionados = $state<Id<'documentacao'>[]>([]); let documentosSelecionados = $state<Id<'documentacao'>[]>([]);
// Queries // Queries - usar funções para garantir reatividade
const documentosQuery = useQuery(api.documentacao.listarDocumentos, { const documentosQuery = useQuery(api.documentacao.listarDocumentos, () => ({
categoriaId: categoriaSelecionada || undefined, categoriaId: categoriaSelecionada || undefined,
tipo: tipoSelecionado || undefined, tipo: tipoSelecionado || undefined,
tags: tagsSelecionadas.length > 0 ? tagsSelecionadas : undefined, tags: tagsSelecionadas.length > 0 ? tagsSelecionadas : undefined,
busca: busca.trim() || undefined, busca: busca.trim() || undefined,
ativo: true, ativo: true,
limite: 50 limite: 50
}); }));
const categoriasQuery = useQuery(api.documentacao.listarCategorias, { const categoriasQuery = useQuery(api.documentacao.listarCategorias, () => ({
ativo: true ativo: true
}); }));
const tagsQuery = useQuery(api.documentacao.listarTags, { const tagsQuery = useQuery(api.documentacao.listarTags, () => ({
ativo: true, ativo: true,
limite: 50 limite: 50
}); }));
// Dados derivados // Dados derivados
const documentos = $derived.by(() => { const documentos = $derived.by(() => {
if (!documentosQuery) return []; if (!documentosQuery || !documentosQuery.data) return [];
return documentosQuery.documentos || []; const data = documentosQuery.data;
return Array.isArray(data.documentos) ? data.documentos : [];
});
const totalDocumentos = $derived.by(() => {
if (!documentosQuery || !documentosQuery.data) return 0;
const data = documentosQuery.data;
return typeof data.total === 'number' ? data.total : 0;
}); });
const categorias = $derived.by(() => { const categorias = $derived.by(() => {
if (!categoriasQuery) return []; if (!categoriasQuery || !categoriasQuery.data) return [];
return categoriasQuery || []; return Array.isArray(categoriasQuery.data) ? categoriasQuery.data : [];
}); });
const tags = $derived.by(() => { const tags = $derived.by(() => {
if (!tagsQuery) return []; if (!tagsQuery || !tagsQuery.data) return [];
return tagsQuery || []; return Array.isArray(tagsQuery.data) ? tagsQuery.data : [];
}); });
// Funções // Funções
@@ -93,6 +88,14 @@
} }
mostrarPdfGenerator = true; mostrarPdfGenerator = true;
} }
function abrirModal(documentoId: Id<'documentacao'>) {
documentoModalId = documentoId;
}
function fecharModal() {
documentoModalId = null;
}
</script> </script>
<ProtectedRoute allowedRoles={['ti_master', 'ti_usuario', 'admin']} maxLevel={1}> <ProtectedRoute allowedRoles={['ti_master', 'ti_usuario', 'admin']} maxLevel={1}>
@@ -102,7 +105,9 @@
class="border-primary/25 from-primary/10 via-base-100 to-secondary/20 relative overflow-hidden rounded-3xl border bg-linear-to-br p-8 shadow-2xl" class="border-primary/25 from-primary/10 via-base-100 to-secondary/20 relative overflow-hidden rounded-3xl border bg-linear-to-br p-8 shadow-2xl"
> >
<div class="bg-primary/20 absolute top-10 -left-10 h-40 w-40 rounded-full blur-3xl"></div> <div class="bg-primary/20 absolute top-10 -left-10 h-40 w-40 rounded-full blur-3xl"></div>
<div class="bg-secondary/20 absolute right-0 -bottom-16 h-56 w-56 rounded-full blur-3xl"></div> <div
class="bg-secondary/20 absolute right-0 -bottom-16 h-56 w-56 rounded-full blur-3xl"
></div>
<div class="relative z-10 flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between"> <div class="relative z-10 flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div class="max-w-3xl space-y-4"> <div class="max-w-3xl space-y-4">
<span <span
@@ -119,10 +124,7 @@
</p> </p>
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
<a <a href={resolve('/ti/documentacao/configuracao')} class="btn btn-primary gap-2">
href={resolve('/ti/documentacao/configuracao')}
class="btn btn-primary gap-2"
>
<Settings class="h-5 w-5" /> <Settings class="h-5 w-5" />
Configuração Configuração
</a> </a>
@@ -137,7 +139,7 @@
</section> </section>
<!-- Busca e Filtros --> <!-- Busca e Filtros -->
<section class="bg-base-100 rounded-2xl border border-base-300 p-6 shadow-lg"> <section class="bg-base-100 border-base-300 rounded-2xl border p-6 shadow-lg">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<DocumentacaoSearch bind:busca bind:mostrarFiltros /> <DocumentacaoSearch bind:busca bind:mostrarFiltros />
@@ -150,9 +152,7 @@
Filtros Filtros
</button> </button>
{#if busca || categoriaSelecionada || tipoSelecionado || tagsSelecionadas.length > 0} {#if busca || categoriaSelecionada || tipoSelecionado || tagsSelecionadas.length > 0}
<button class="btn btn-ghost btn-sm gap-2" onclick={limparFiltros}> <button class="btn btn-ghost btn-sm gap-2" onclick={limparFiltros}> Limpar </button>
Limpar
</button>
{/if} {/if}
</div> </div>
</div> </div>
@@ -178,7 +178,7 @@
Documentos Documentos
{#if documentosQuery} {#if documentosQuery}
<span class="text-base-content/50 text-lg font-normal"> <span class="text-base-content/50 text-lg font-normal">
({documentosQuery.total}) ({totalDocumentos})
</span> </span>
{/if} {/if}
</h2> </h2>
@@ -189,7 +189,7 @@
<span class="loading loading-spinner loading-lg"></span> <span class="loading loading-spinner loading-lg"></span>
</div> </div>
{:else if documentos.length === 0} {:else if documentos.length === 0}
<div class="bg-base-200 rounded-2xl border border-base-300 p-12 text-center"> <div class="bg-base-200 border-base-300 rounded-2xl border p-12 text-center">
<BookOpen class="text-base-content/30 mx-auto mb-4 h-16 w-16" /> <BookOpen class="text-base-content/30 mx-auto mb-4 h-16 w-16" />
<h3 class="text-base-content mb-2 text-xl font-semibold">Nenhum documento encontrado</h3> <h3 class="text-base-content mb-2 text-xl font-semibold">Nenhum documento encontrado</h3>
<p class="text-base-content/70"> <p class="text-base-content/70">
@@ -208,6 +208,7 @@
{documento} {documento}
selecionado={documentosSelecionados.includes(documento._id)} selecionado={documentosSelecionados.includes(documento._id)}
onToggleSelecao={() => toggleSelecaoDocumento(documento._id)} onToggleSelecao={() => toggleSelecaoDocumento(documento._id)}
onVisualizar={() => abrirModal(documento._id)}
/> />
{/each} {/each}
</div> </div>
@@ -225,5 +226,7 @@
}} }}
/> />
{/if} {/if}
</ProtectedRoute>
<!-- Modal de Visualização -->
<DocumentacaoModal documentoId={documentoModalId} onClose={fecharModal} />
</ProtectedRoute>

View File

@@ -7,8 +7,9 @@
import { format } from 'date-fns'; import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale'; import { ptBR } from 'date-fns/locale';
import { marked } from 'marked'; import { marked } from 'marked';
import { ArrowLeft, Download, Calendar, User, Tag, FileText } from 'lucide-svelte'; import { ArrowLeft, Download, Calendar, User, Tag, FileText, BookOpen } from 'lucide-svelte';
import jsPDF from 'jspdf'; import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
const documentoId = $derived(page.params.id); const documentoId = $derived(page.params.id);
const documentoQuery = useQuery( const documentoQuery = useQuery(
@@ -18,58 +19,196 @@
let gerandoPdf = $state(false); let gerandoPdf = $state(false);
// 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?.conteudo) return '';
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;
});
async function gerarPdfIndividual() { async function gerarPdfIndividual() {
if (!documentoQuery) return; if (!documentoQuery) return;
try { try {
gerandoPdf = true; gerandoPdf = true;
const doc = new jsPDF(); const doc = new jsPDF();
let yPos = 20; const pageHeight = doc.internal.pageSize.getHeight();
const pageWidth = doc.internal.pageSize.getWidth();
const margin = 20; const margin = 20;
const primaryColor = [102, 126, 234];
const secondaryColor = [128, 128, 128];
// Título // Cabeçalho
doc.setFontSize(20); doc.setFillColor(...primaryColor);
doc.setTextColor(102, 126, 234); doc.rect(0, 0, pageWidth, 40, 'F');
doc.text(documentoQuery.titulo, margin, yPos, { maxWidth: 170 }); doc.setTextColor(255, 255, 255);
yPos += 15; doc.setFontSize(18);
doc.setFont('helvetica', 'bold');
doc.text(documentoQuery.titulo, pageWidth / 2, 25, { align: 'center', maxWidth: pageWidth - 2 * margin });
// Metadados let yPos = 50;
// Metadados em tabela
autoTable(doc, {
startY: yPos,
body: [
['Tipo', documentoQuery.tipo],
['Versão', documentoQuery.versao],
['Arquivo Origem', documentoQuery.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 processado
doc.setFontSize(10); doc.setFontSize(10);
doc.setTextColor(128, 128, 128);
doc.text(`Tipo: ${documentoQuery.tipo} | Versão: ${documentoQuery.versao}`, margin, yPos);
yPos += 10;
// Conteúdo
doc.setFontSize(11);
doc.setTextColor(0, 0, 0); doc.setTextColor(0, 0, 0);
doc.setFont('helvetica', 'normal');
let conteudoTexto = documentoQuery.conteudo; let conteudoTexto = documentoQuery.conteudo;
conteudoTexto = conteudoTexto.replace(/#{1,6}\s+/g, ''); const secoes = conteudoTexto.split(/\n##\s+/);
conteudoTexto = conteudoTexto.replace(/\*\*(.*?)\*\*/g, '$1');
conteudoTexto = conteudoTexto.replace(/\*(.*?)\*/g, '$1');
conteudoTexto = conteudoTexto.replace(/`(.*?)`/g, '$1');
conteudoTexto = conteudoTexto.replace(/\[(.*?)\]\(.*?\)/g, '$1');
const linhas = doc.splitTextToSize(conteudoTexto, 170); for (let j = 0; j < secoes.length; j++) {
for (const linha of linhas) { let secao = secoes[j];
if (yPos > doc.internal.pageSize.getHeight() - 20) { if (j === 0) {
secao = secao.replace(/^#\s+.*?\n\n/, '');
}
const tituloMatch = secao.match(/^(.+?)\n/);
if (tituloMatch && j > 0) {
if (yPos > pageHeight - 30) {
doc.addPage(); doc.addPage();
yPos = margin; yPos = margin;
} }
doc.text(linha, margin, yPos); doc.setFontSize(12);
yPos += 6; doc.setTextColor(...primaryColor);
doc.setFont('helvetica', 'bold');
doc.text(tituloMatch[1], margin, yPos);
yPos += 8;
secao = secao.substring(tituloMatch[0].length);
}
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
doc.setFont('helvetica', 'normal');
secao = secao.replace(/\*\*(.*?)\*\*/g, '$1');
secao = secao.replace(/\*(.*?)\*/g, '$1');
secao = secao.replace(/`(.*?)`/g, '$1');
secao = secao.replace(/\[(.*?)\]\(.*?\)/g, '$1');
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('*')) {
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('###')) {
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()) {
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 {
yPos += 3;
}
}
yPos += 5;
} }
// Footer // Footer
const pageCount = doc.getNumberOfPages(); const pageCount = doc.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) { for (let i = 1; i <= pageCount; i++) {
doc.setPage(i); doc.setPage(i);
doc.setFillColor(240, 240, 240);
doc.rect(0, pageHeight - 15, pageWidth, 15, 'F');
doc.setFontSize(8); doc.setFontSize(8);
doc.setTextColor(128, 128, 128); doc.setTextColor(...secondaryColor);
doc.setFont('helvetica', 'normal');
doc.text( doc.text(
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`, `SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
doc.internal.pageSize.getWidth() / 2, pageWidth / 2,
doc.internal.pageSize.getHeight() - 10, pageHeight - 7,
{ align: 'center' } { align: 'center' }
); );
} }
@@ -112,80 +251,159 @@
</div> </div>
{:else} {:else}
<!-- Documento --> <!-- Documento -->
<article class="bg-base-100 rounded-2xl border border-base-300 p-8 shadow-lg"> <div class="flex gap-6">
<!-- Título --> <!-- Índice lateral -->
<header class="mb-6"> {#if indice().length > 0}
<h1 class="text-base-content mb-4 text-4xl font-bold">{documentoQuery.titulo}</h1> <aside class="hidden w-64 shrink-0 lg:block">
<div class="flex flex-wrap items-center gap-4 text-sm text-base-content/70"> <div class="bg-base-100 border-base-300 sticky top-8 rounded-2xl border p-6 shadow-lg">
<div class="flex items-center gap-2"> <h3 class="text-base-content mb-4 text-sm font-semibold uppercase">Índice</h3>
<FileText class="h-4 w-4" /> <nav class="space-y-1">
<span class="badge badge-primary">{documentoQuery.tipo}</span> {#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> </div>
</aside>
{/if}
<!-- Conteúdo principal -->
<article class="bg-base-100 flex-1 rounded-2xl border border-base-300 shadow-lg">
<!-- Cabeçalho com gradiente -->
<header class="bg-gradient-to-r from-primary to-primary-focus rounded-t-2xl p-8 text-white">
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<div class="mb-3 flex items-center gap-2">
<BookOpen class="h-6 w-6" />
<span class="badge badge-secondary badge-lg">{documentoQuery.tipo}</span>
</div>
<h1 class="mb-4 text-4xl font-bold">{documentoQuery.titulo}</h1>
<div class="flex flex-wrap items-center gap-4 text-sm text-white/90">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Calendar class="h-4 w-4" /> <Calendar class="h-4 w-4" />
<span> <span>
{format(new Date(documentoQuery.atualizadoEm), "dd/MM/yyyy 'às' HH:mm", { Atualizado em {documentoQuery.atualizadoEm && !isNaN(new Date(documentoQuery.atualizadoEm).getTime())
? format(new Date(documentoQuery.atualizadoEm), "dd/MM/yyyy 'às' HH:mm", {
locale: ptBR locale: ptBR
})} })
: 'Data não disponível'}
</span> </span>
</div> </div>
{#if documentoQuery.criadoPorUsuario} {#if documentoQuery.criadoPorUsuario}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<User class="h-4 w-4" /> <User class="h-4 w-4" />
<span>{documentoQuery.criadoPorUsuario.nome}</span> <span>Por {documentoQuery.criadoPorUsuario.nome}</span>
</div> </div>
{/if} {/if}
<div class="flex items-center gap-2">
<FileText class="h-4 w-4" />
<span>Versão {documentoQuery.versao}</span>
</div>
</div>
</div>
</div> </div>
</header> </header>
<!-- Conteúdo principal -->
<div class="p-8">
<!-- Tags --> <!-- Tags -->
{#if documentoQuery.tags && documentoQuery.tags.length > 0} {#if documentoQuery.tags && documentoQuery.tags.length > 0}
<div class="mb-6 flex flex-wrap items-center gap-2"> <div class="mb-6 flex flex-wrap items-center gap-2">
<Tag class="text-base-content/50 h-5 w-5" /> <Tag class="text-base-content/50 h-5 w-5" />
{#each documentoQuery.tags as tag} {#each documentoQuery.tags as tag}
<span class="badge badge-outline">{tag}</span> <span class="badge badge-outline badge-sm">{tag}</span>
{/each} {/each}
</div> </div>
{/if} {/if}
<!-- Conteúdo --> <!-- Conteúdo Markdown -->
<div class="prose prose-slate max-w-none"> <div class="prose prose-slate prose-lg max-w-none">
{@html marked.parse(documentoQuery.conteudo)} {@html conteudoHtml()}
</div> </div>
<!-- Metadados --> <!-- Informações adicionais -->
{#if documentoQuery.metadados} {#if documentoQuery.arquivoOrigem}
<div class="bg-base-200 mt-8 rounded-lg p-6"> <div class="bg-base-200 mt-8 rounded-lg p-6">
<h3 class="text-base-content mb-4 text-lg font-semibold">Informações Técnicas</h3> <h3 class="text-base-content mb-4 flex items-center gap-2 text-lg font-semibold">
{#if documentoQuery.metadados.parametros} <FileText class="h-5 w-5" />
<div class="mb-4"> Informações do Arquivo
<h4 class="text-base-content mb-2 font-medium">Parâmetros:</h4> </h3>
<ul class="list-inside list-disc"> <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">{documentoQuery.arquivoOrigem}</span>
</div>
{#if documentoQuery.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">{documentoQuery.funcaoOrigem}</span>
</div>
{/if}
<div>
<span class="text-base-content/70 font-medium">Hash:</span>
<span class="text-base-content ml-2 font-mono text-xs">{documentoQuery.hash}</span>
</div>
</div>
</div>
{/if}
<!-- Metadados técnicos -->
{#if documentoQuery.metadados}
<div class="bg-base-200 mt-6 rounded-lg p-6">
<h3 class="text-base-content mb-4 text-lg font-semibold">Detalhes Técnicos</h3>
<div class="space-y-4">
{#if documentoQuery.metadados.parametros && documentoQuery.metadados.parametros.length > 0}
<div>
<h4 class="text-base-content mb-3 font-medium">Parâmetros de Entrada:</h4>
<div class="bg-base-100 rounded-lg p-4">
<ul class="space-y-2">
{#each documentoQuery.metadados.parametros as param} {#each documentoQuery.metadados.parametros as param}
<li class="text-base-content/70">{param}</li> <li class="text-base-content/80 flex items-start gap-2">
<span class="text-primary mt-1"></span>
<span class="font-mono text-sm">{param}</span>
</li>
{/each} {/each}
</ul> </ul>
</div> </div>
</div>
{/if} {/if}
{#if documentoQuery.metadados.retorno} {#if documentoQuery.metadados.retorno}
<div class="mb-4"> <div>
<h4 class="text-base-content mb-2 font-medium">Retorno:</h4> <h4 class="text-base-content mb-2 font-medium">Valor de Retorno:</h4>
<p class="text-base-content/70">{documentoQuery.metadados.retorno}</p> <div class="bg-base-100 rounded-lg p-4">
<p class="text-base-content/80 font-mono text-sm">{documentoQuery.metadados.retorno}</p>
</div>
</div> </div>
{/if} {/if}
{#if documentoQuery.metadados.dependencias} {#if documentoQuery.metadados.dependencias && documentoQuery.metadados.dependencias.length > 0}
<div class="mb-4"> <div>
<h4 class="text-base-content mb-2 font-medium">Dependências:</h4> <h4 class="text-base-content mb-2 font-medium">Dependências:</h4>
<ul class="list-inside list-disc"> <div class="bg-base-100 rounded-lg p-4">
<ul class="space-y-2">
{#each documentoQuery.metadados.dependencias as dep} {#each documentoQuery.metadados.dependencias as dep}
<li class="text-base-content/70">{dep}</li> <li class="text-base-content/80 flex items-start gap-2">
<span class="text-primary mt-1"></span>
<span>{dep}</span>
</li>
{/each} {/each}
</ul> </ul>
</div> </div>
{/if}
</div> </div>
{/if} {/if}
</div>
</div>
{/if}
</div>
</article> </article>
</div>
{/if} {/if}
</main> </main>
</ProtectedRoute> </ProtectedRoute>
@@ -193,25 +411,121 @@
<style> <style>
:global(.prose) { :global(.prose) {
color: hsl(var(--bc)); 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;
}
:global(.prose h2) {
color: hsl(var(--p));
font-size: 2em;
font-weight: 600;
margin-top: 1.5em;
margin-bottom: 0.75em;
} }
:global(.prose h1),
:global(.prose h2),
:global(.prose h3) { :global(.prose h3) {
color: hsl(var(--p));
font-size: 1.5em;
font-weight: 600;
margin-top: 1.25em;
margin-bottom: 0.5em;
}
:global(.prose h4) {
color: hsl(var(--bc)); color: hsl(var(--bc));
font-size: 1.25em;
font-weight: 600;
margin-top: 1em;
margin-bottom: 0.5em;
}
:global(.prose p) {
margin-bottom: 1em;
line-height: 1.8;
}
:global(.prose ul),
:global(.prose ol) {
margin: 1em 0;
padding-left: 2em;
}
:global(.prose li) {
margin: 0.5em 0;
line-height: 1.8;
} }
:global(.prose code) { :global(.prose code) {
background-color: hsl(var(--b2)); background-color: hsl(var(--b2));
padding: 0.2em 0.4em; padding: 0.2em 0.5em;
border-radius: 0.25rem; border-radius: 0.375rem;
font-size: 0.9em;
font-family: 'Courier New', monospace;
color: hsl(var(--p));
} }
:global(.prose pre) { :global(.prose pre) {
background-color: hsl(var(--b2)); background-color: hsl(var(--b2));
padding: 1rem; padding: 1.25rem;
border-radius: 0.5rem; border-radius: 0.5rem;
overflow-x: auto; overflow-x: auto;
border-left: 4px solid hsl(var(--p));
margin: 1.5em 0;
}
:global(.prose pre code) {
background-color: transparent;
padding: 0;
color: hsl(var(--bc));
}
:global(.prose strong) {
color: hsl(var(--bc));
font-weight: 600;
}
:global(.prose a) {
color: hsl(var(--p));
text-decoration: underline;
}
:global(.prose a:hover) {
color: hsl(var(--pf));
}
:global(.prose blockquote) {
border-left: 4px solid hsl(var(--p));
padding-left: 1.5em;
margin: 1.5em 0;
color: hsl(var(--bc) / 0.8);
font-style: italic;
}
:global(.prose table) {
width: 100%;
border-collapse: collapse;
margin: 1.5em 0;
}
:global(.prose th),
:global(.prose td) {
border: 1px solid hsl(var(--b3));
padding: 0.75em;
text-align: left;
}
:global(.prose th) {
background-color: hsl(var(--b2));
font-weight: 600;
} }
</style> </style>

View File

@@ -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 ProtectedRoute from '$lib/components/ProtectedRoute.svelte'; import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale'; import { ptBR } from 'date-fns/locale';
@@ -13,7 +14,9 @@
Calendar, Calendar,
CheckCircle2, CheckCircle2,
XCircle, XCircle,
Loader2 Loader2,
X,
CheckCircle
} from 'lucide-svelte'; } from 'lucide-svelte';
const client = useConvexClient(); const client = useConvexClient();
@@ -22,17 +25,17 @@
type DiaSemana = 'domingo' | 'segunda' | 'terca' | 'quarta' | 'quinta' | 'sexta' | 'sabado'; type DiaSemana = 'domingo' | 'segunda' | 'terca' | 'quarta' | 'quinta' | 'sexta' | 'sabado';
type ConfigVarredura = { type ConfigVarredura = {
_id: Id<'documentacaoConfig'>; _id?: Id<'documentacaoConfig'>;
ativo: boolean; ativo: boolean;
diasSemana: DiaSemana[]; diasSemana: DiaSemana[];
horario: string; horario: string;
fusoHorario?: string; fusoHorario?: string;
ultimaExecucao?: number; ultimaExecucao?: number;
proximaExecucao?: number; proximaExecucao?: number;
configuradoPor: Id<'usuarios'>; configuradoPor?: Id<'usuarios'>;
configuradoEm: number; configuradoEm?: number;
atualizadoPor?: Id<'usuarios'>; atualizadoPor?: Id<'usuarios'>;
atualizadoEm: number; atualizadoEm?: number;
}; };
type Varredura = { type Varredura = {
@@ -53,13 +56,30 @@
nome: string; nome: string;
email: string; email: string;
} | null; } | null;
arquivosModificados?: Array<{
arquivo: string;
versao: string;
funcoes: string[];
}>;
totalArquivosModificados?: number;
}; };
// Estados // Estados - inicializar com valores padrão
let config = $state<ConfigVarredura | null>(null); let config = $state<ConfigVarredura>({
ativo: false,
diasSemana: [],
horario: '08:00',
fusoHorario: 'America/Recife'
});
let executandoVarredura = $state(false); let executandoVarredura = $state(false);
let historicoVarreduras = $state<Varredura[]>([]); let historicoVarreduras = $state<Varredura[]>([]);
// Estados para modais
let showSuccessModal = $state(false);
let showErrorModal = $state(false);
let modalMessage = $state('');
let modalTitle = $state('');
// Queries // Queries
const configQuery = useQuery(api.documentacao.obterConfigVarredura, {}); const configQuery = useQuery(api.documentacao.obterConfigVarredura, {});
@@ -67,18 +87,33 @@
$effect(() => { $effect(() => {
if (configQuery) { if (configQuery) {
config = { config = {
_id: configQuery._id,
ativo: configQuery.ativo ?? false, ativo: configQuery.ativo ?? false,
diasSemana: configQuery.diasSemana ?? [], diasSemana: configQuery.diasSemana ?? [],
horario: configQuery.horario ?? '08:00', horario: configQuery.horario ?? '08:00',
fusoHorario: configQuery.fusoHorario ?? 'America/Recife' fusoHorario: configQuery.fusoHorario ?? 'America/Recife',
ultimaExecucao: configQuery.ultimaExecucao,
proximaExecucao: configQuery.proximaExecucao,
configuradoPor: configQuery.configuradoPor,
configuradoEm: configQuery.configuradoEm,
atualizadoPor: configQuery.atualizadoPor,
atualizadoEm: configQuery.atualizadoEm
}; };
} else if (configQuery === null) {
// Se não há configuração, criar uma padrão
if (!config || !config._id) {
config = {
ativo: false,
diasSemana: [],
horario: '08:00',
fusoHorario: 'America/Recife'
};
}
} }
}); });
// Funções // Funções
async function salvarConfig() { async function salvarConfig() {
if (!config) return;
try { try {
await client.mutation(api.documentacao.salvarConfigVarredura, { await client.mutation(api.documentacao.salvarConfigVarredura, {
ativo: config.ativo, ativo: config.ativo,
@@ -86,10 +121,14 @@
horario: config.horario, horario: config.horario,
fusoHorario: config.fusoHorario fusoHorario: config.fusoHorario
}); });
alert('Configuração salva com sucesso!'); modalTitle = 'Sucesso';
modalMessage = 'Configuração salva com sucesso!';
showSuccessModal = true;
} catch (error) { } catch (error) {
console.error('Erro ao salvar configuração:', error); console.error('Erro ao salvar configuração:', error);
alert('Erro ao salvar configuração'); modalTitle = 'Erro';
modalMessage = 'Erro ao salvar configuração. Tente novamente.';
showErrorModal = true;
} }
} }
@@ -97,32 +136,57 @@
try { try {
executandoVarredura = true; executandoVarredura = true;
await client.mutation(api.documentacaoVarredura.executarVarreduraManual, {}); await client.mutation(api.documentacaoVarredura.executarVarreduraManual, {});
alert('Varredura iniciada! Você será notificado quando concluir.'); modalTitle = 'Varredura Iniciada';
// Recarregar histórico após alguns segundos modalMessage = 'Varredura iniciada! Você será notificado quando concluir.';
setTimeout(() => { showSuccessModal = true;
carregarHistorico();
}, 3000);
} catch (error) { } catch (error) {
console.error('Erro ao executar varredura:', error); console.error('Erro ao executar varredura:', error);
alert('Erro ao executar varredura'); modalTitle = 'Erro';
modalMessage = 'Erro ao executar varredura. Tente novamente.';
showErrorModal = true;
} finally { } finally {
executandoVarredura = false; executandoVarredura = false;
} }
} }
async function carregarHistorico() { function fecharModal() {
try { showSuccessModal = false;
const historico = await client.mutation(api.documentacaoVarredura.obterHistoricoVarreduras, { showErrorModal = false;
limite: 20 modalMessage = '';
}); modalTitle = '';
historicoVarreduras = historico || [];
} catch (error) {
console.error('Erro ao carregar histórico:', error);
}
} }
const historicoQuery = useQuery(api.documentacaoVarredura.obterHistoricoVarreduras, () => ({
limite: 20
}));
// Extrair dados do histórico
$effect(() => { $effect(() => {
carregarHistorico(); if (historicoQuery === undefined) {
historicoVarreduras = [];
return;
}
if (historicoQuery === null) {
historicoVarreduras = [];
return;
}
// Se é diretamente um array, usar ele (caso mais comum)
if (Array.isArray(historicoQuery)) {
historicoVarreduras = historicoQuery;
return;
}
// Se tem propriedade data, usar os dados
if (typeof historicoQuery === 'object' && historicoQuery !== null && 'data' in historicoQuery) {
const data = (historicoQuery as any).data;
historicoVarreduras = Array.isArray(data) ? data : [];
return;
}
// Caso padrão
historicoVarreduras = [];
}); });
const diasSemana = [ const diasSemana = [
@@ -136,7 +200,6 @@
]; ];
function toggleDiaSemana(dia: DiaSemana) { function toggleDiaSemana(dia: DiaSemana) {
if (!config) return;
if (config.diasSemana.includes(dia)) { if (config.diasSemana.includes(dia)) {
config.diasSemana = config.diasSemana.filter((d) => d !== dia); config.diasSemana = config.diasSemana.filter((d) => d !== dia);
} else { } else {
@@ -186,10 +249,6 @@
<div class="flex items-center justify-center py-12"> <div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg"></span> <span class="loading loading-spinner loading-lg"></span>
</div> </div>
{:else if !config}
<div class="bg-base-200 rounded-2xl border border-base-300 p-12 text-center">
<p class="text-base-content/70">Carregando configuração...</p>
</div>
{:else} {:else}
<!-- Configuração --> <!-- Configuração -->
<section class="bg-base-100 rounded-2xl border border-base-300 p-6 shadow-lg"> <section class="bg-base-100 rounded-2xl border border-base-300 p-6 shadow-lg">
@@ -199,6 +258,51 @@
</h2> </h2>
<div class="space-y-6"> <div class="space-y-6">
<!-- Informações da Configuração Atual -->
{#if config._id}
<div class="bg-base-200 rounded-lg p-4">
<h3 class="text-base-content mb-3 text-lg font-semibold">Configuração Atual</h3>
<div class="grid grid-cols-1 gap-2 text-sm md:grid-cols-2">
<div>
<span class="text-base-content/70 font-medium">Status:</span>
<span class="text-base-content ml-2">
{config.ativo ? 'Ativa' : 'Inativa'}
</span>
</div>
{#if config.ultimaExecucao && !isNaN(new Date(config.ultimaExecucao).getTime())}
<div>
<span class="text-base-content/70 font-medium">Última execução:</span>
<span class="text-base-content ml-2">
{format(new Date(config.ultimaExecucao), "dd/MM/yyyy 'às' HH:mm", {
locale: ptBR
})}
</span>
</div>
{/if}
{#if config.proximaExecucao && !isNaN(new Date(config.proximaExecucao).getTime())}
<div>
<span class="text-base-content/70 font-medium">Próxima execução:</span>
<span class="text-base-content ml-2">
{format(new Date(config.proximaExecucao), "dd/MM/yyyy 'às' HH:mm", {
locale: ptBR
})}
</span>
</div>
{/if}
{#if config.atualizadoEm && !isNaN(new Date(config.atualizadoEm).getTime())}
<div>
<span class="text-base-content/70 font-medium">Atualizado em:</span>
<span class="text-base-content ml-2">
{format(new Date(config.atualizadoEm), "dd/MM/yyyy 'às' HH:mm", {
locale: ptBR
})}
</span>
</div>
{/if}
</div>
</div>
{/if}
<!-- Ativar/Desativar --> <!-- Ativar/Desativar -->
<div class="form-control"> <div class="form-control">
<label class="label cursor-pointer"> <label class="label cursor-pointer">
@@ -223,7 +327,7 @@
type="checkbox" type="checkbox"
class="checkbox checkbox-primary" class="checkbox checkbox-primary"
checked={config.diasSemana.includes(dia.value)} checked={config.diasSemana.includes(dia.value)}
onchange={() => toggleDiaSemana(dia.value)} onclick={() => toggleDiaSemana(dia.value)}
/> />
<span>{dia.label}</span> <span>{dia.label}</span>
</label> </label>
@@ -272,57 +376,90 @@
Histórico de Varreduras Histórico de Varreduras
</h2> </h2>
{#if historicoVarreduras.length === 0} {#if historicoQuery === undefined}
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if historicoVarreduras.length === 0}
<div class="bg-base-200 rounded-lg p-8 text-center"> <div class="bg-base-200 rounded-lg p-8 text-center">
<p class="text-base-content/70">Nenhuma varredura executada ainda</p> <p class="text-base-content/70">Nenhuma varredura executada ainda</p>
</div> </div>
{:else} {:else}
<div class="overflow-x-auto"> <div class="space-y-4">
<table class="table">
<thead>
<tr>
<th>Tipo</th>
<th>Status</th>
<th>Documentos</th>
<th>Executado por</th>
<th>Iniciado em</th>
<th>Duração</th>
</tr>
</thead>
<tbody>
{#each historicoVarreduras as varredura} {#each historicoVarreduras as varredura}
<tr> <div class="bg-base-200 rounded-lg border border-base-300 p-4">
<td> <div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="badge badge-outline"> <span class="badge badge-outline">
{varredura.tipo === 'automatica' ? 'Automática' : 'Manual'} {varredura.tipo === 'automatica' ? 'Automática' : 'Manual'}
</span> </span>
</td>
<td>
<span class="badge {statusColors[varredura.status] || 'badge-ghost'}"> <span class="badge {statusColors[varredura.status] || 'badge-ghost'}">
{statusLabels[varredura.status] || varredura.status} {statusLabels[varredura.status] || varredura.status}
</span> </span>
</td> <span class="text-base-content/70 text-sm">
<td> {varredura.iniciadoEm && !isNaN(new Date(varredura.iniciadoEm).getTime())
Novos: {varredura.documentosNovos} | Atualizados:{' '} ? format(new Date(varredura.iniciadoEm), "dd/MM/yyyy 'às' HH:mm", {
{varredura.documentosAtualizados}
</td>
<td>
{varredura.executadoPorUsuario?.nome || 'N/A'}
</td>
<td>
{format(new Date(varredura.iniciadoEm), "dd/MM/yyyy 'às' HH:mm", {
locale: ptBR locale: ptBR
})} })
</td> : 'Data não disponível'}
<td> </span>
</div>
<div class="text-base-content/70 text-sm">
{varredura.duracaoMs {varredura.duracaoMs
? `${(varredura.duracaoMs / 1000).toFixed(1)}s` ? `${(varredura.duracaoMs / 1000).toFixed(1)}s`
: '-'} : '-'}
</td> </div>
</tr> </div>
<div class="mb-3 grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
<div>
<span class="text-base-content/70 font-medium">Arquivos analisados:</span>
<span class="text-base-content ml-2">{varredura.arquivosAnalisados}</span>
</div>
<div>
<span class="text-base-content/70 font-medium">Documentos novos:</span>
<span class="text-base-content ml-2">{varredura.documentosNovos}</span>
</div>
<div>
<span class="text-base-content/70 font-medium">Documentos atualizados:</span>
<span class="text-base-content ml-2">{varredura.documentosAtualizados}</span>
</div>
<div>
<span class="text-base-content/70 font-medium">Executado por:</span>
<span class="text-base-content ml-2">
{varredura.executadoPorUsuario?.nome || 'N/A'}
</span>
</div>
</div>
{#if varredura.totalArquivosModificados !== undefined && varredura.totalArquivosModificados > 0}
<div class="border-base-300 mt-3 border-t pt-3">
<div class="mb-2 flex items-center gap-2">
<span class="text-base-content font-semibold">
Arquivos modificados: {varredura.totalArquivosModificados}
</span>
</div>
<div class="space-y-2">
{#each varredura.arquivosModificados || [] as arquivo}
<div class="bg-base-100 rounded-lg p-3">
<div class="mb-1 flex items-center justify-between">
<span class="text-base-content font-mono text-sm font-medium">
{arquivo.arquivo}
</span>
<span class="badge badge-primary badge-sm">v{arquivo.versao}</span>
</div>
{#if arquivo.funcoes.length > 0}
<div class="text-base-content/70 mt-1 text-xs">
Funções: {arquivo.funcoes.join(', ')}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
{/each} {/each}
</tbody>
</table>
</div> </div>
{/if} {/if}
</section> </section>
@@ -330,3 +467,55 @@
</main> </main>
</ProtectedRoute> </ProtectedRoute>
<!-- Modal de Sucesso -->
{#if showSuccessModal}
<div class="modal modal-open">
<div class="modal-box from-base-100 to-base-200 bg-linear-to-br">
<button
type="button"
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
onclick={fecharModal}
>
<X class="h-5 w-5" />
</button>
<div class="flex items-center gap-3 mb-4">
<CheckCircle class="text-success h-8 w-8" />
<h3 class="text-primary text-2xl font-bold">{modalTitle}</h3>
</div>
<p class="text-base-content mb-6">{modalMessage}</p>
<div class="modal-action">
<button class="btn btn-primary" onclick={fecharModal}>OK</button>
</div>
</div>
<form method="dialog" class="modal-backdrop" onclick={fecharModal}>
<button type="button">close</button>
</form>
</div>
{/if}
<!-- Modal de Erro -->
{#if showErrorModal}
<div class="modal modal-open">
<div class="modal-box from-base-100 to-base-200 bg-linear-to-br">
<button
type="button"
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
onclick={fecharModal}
>
<X class="h-5 w-5" />
</button>
<div class="flex items-center gap-3 mb-4">
<XCircle class="text-error h-8 w-8" />
<h3 class="text-error text-2xl font-bold">{modalTitle}</h3>
</div>
<p class="text-base-content mb-6">{modalMessage}</p>
<div class="modal-action">
<button class="btn btn-error" onclick={fecharModal}>Fechar</button>
</div>
</div>
<form method="dialog" class="modal-backdrop" onclick={fecharModal}>
<button type="button">close</button>
</form>
</div>
{/if}

View File

@@ -9,6 +9,7 @@
*/ */
import type * as acoes from "../acoes.js"; import type * as acoes from "../acoes.js";
import type * as actions_documentacaoVarredura from "../actions/documentacaoVarredura.js";
import type * as actions_email from "../actions/email.js"; import type * as actions_email from "../actions/email.js";
import type * as actions_linkPreview from "../actions/linkPreview.js"; import type * as actions_linkPreview from "../actions/linkPreview.js";
import type * as actions_pushNotifications from "../actions/pushNotifications.js"; import type * as actions_pushNotifications from "../actions/pushNotifications.js";
@@ -100,6 +101,7 @@ import type {
declare const fullApi: ApiFromModules<{ declare const fullApi: ApiFromModules<{
acoes: typeof acoes; acoes: typeof acoes;
"actions/documentacaoVarredura": typeof actions_documentacaoVarredura;
"actions/email": typeof actions_email; "actions/email": typeof actions_email;
"actions/linkPreview": typeof actions_linkPreview; "actions/linkPreview": typeof actions_linkPreview;
"actions/pushNotifications": typeof actions_pushNotifications; "actions/pushNotifications": typeof actions_pushNotifications;

View File

@@ -0,0 +1,137 @@
'use node';
import { action } from '../_generated/server';
import { v } from 'convex/values';
import { internal } from '../_generated/api';
import * as fs from 'fs';
import * as path from 'path';
/**
* Action para ler arquivos do sistema de arquivos e analisar funções
*/
export const analisarArquivos = action({
args: {
varreduraId: v.id('documentacaoVarredura'),
arquivos: v.array(v.string()),
executadoPor: v.id('usuarios')
},
returns: v.object({
arquivosAnalisados: v.number(),
documentosNovos: v.number(),
documentosAtualizados: v.number(),
erros: v.array(v.string())
}),
handler: async (ctx, args) => {
'use node';
const erros: string[] = [];
let arquivosAnalisados = 0;
let documentosNovos = 0;
let documentosAtualizados = 0;
console.log('🔍 [analisarArquivos] Iniciando análise de arquivos...');
console.log(`📁 Total de arquivos: ${args.arquivos.length}`);
console.log(`📋 Arquivos: ${args.arquivos.join(', ')}`);
// Tentar diferentes caminhos possíveis
const caminhosPossiveis = [
path.join(process.cwd(), 'packages/backend/convex'),
path.join(process.cwd(), 'convex'),
path.resolve(__dirname, '../../convex'),
path.resolve(__dirname, '../../../convex')
];
let basePath: string | null = null;
for (const caminho of caminhosPossiveis) {
if (fs.existsSync(caminho)) {
basePath = caminho;
console.log(`✅ Caminho encontrado: ${basePath}`);
break;
}
}
if (!basePath) {
const erro = `Nenhum caminho válido encontrado. Tentados: ${caminhosPossiveis.join(', ')}`;
console.error(`${erro}`);
erros.push(erro);
await ctx.runMutation(internal.documentacaoVarredura.atualizarVarreduraComResultados, {
varreduraId: args.varreduraId,
documentosNovos: 0,
documentosAtualizados: 0,
arquivosAnalisados: 0,
erros: [erro]
});
return {
arquivosAnalisados: 0,
documentosNovos: 0,
documentosAtualizados: 0,
erros: [erro]
};
}
for (const nomeArquivo of args.arquivos) {
try {
const arquivoPath = path.join(basePath, nomeArquivo);
console.log(`📄 Processando: ${arquivoPath}`);
// Verificar se o arquivo existe
if (!fs.existsSync(arquivoPath)) {
const erro = `Arquivo não encontrado: ${nomeArquivo} (caminho: ${arquivoPath})`;
console.error(`${erro}`);
erros.push(erro);
continue;
}
// Ler conteúdo do arquivo
const conteudo = fs.readFileSync(arquivoPath, 'utf-8');
console.log(`✅ Arquivo lido: ${nomeArquivo} (${conteudo.length} caracteres)`);
// Analisar funções no arquivo usando a mutation interna
const resultado = await ctx.runMutation(
internal.documentacaoVarredura.processarArquivo,
{
varreduraId: args.varreduraId,
nomeArquivo,
conteudo,
executadoPor: args.executadoPor
}
);
console.log(
`✅ Processado ${nomeArquivo}: ${resultado.documentosNovos} novos, ${resultado.documentosAtualizados} atualizados`
);
arquivosAnalisados++;
documentosNovos += resultado.documentosNovos;
documentosAtualizados += resultado.documentosAtualizados;
} catch (error) {
const erroMsg = error instanceof Error ? error.message : 'Erro desconhecido';
const erroStack = error instanceof Error ? error.stack : undefined;
const erroCompleto = `Erro ao processar ${nomeArquivo}: ${erroMsg}${erroStack ? `\n${erroStack}` : ''}`;
console.error(`${erroCompleto}`);
erros.push(erroCompleto);
}
}
console.log(
`📊 Resumo: ${arquivosAnalisados} arquivos analisados, ${documentosNovos} novos, ${documentosAtualizados} atualizados`
);
// Atualizar varredura com os resultados
await ctx.runMutation(internal.documentacaoVarredura.atualizarVarreduraComResultados, {
varreduraId: args.varreduraId,
documentosNovos,
documentosAtualizados,
arquivosAnalisados,
erros
});
return {
arquivosAnalisados,
documentosNovos,
documentosAtualizados,
erros
};
}
});

View File

@@ -1,11 +1,22 @@
import { v } from 'convex/values'; import { v } from 'convex/values';
import { internalMutation, mutation } from './_generated/server'; import { internalMutation, mutation, query } from './_generated/server';
import { Doc, Id } from './_generated/dataModel'; import { Doc, Id } from './_generated/dataModel';
import { internal, api } from './_generated/api'; import { internal, api } from './_generated/api';
import { getCurrentUserFunction } from './auth'; import { getCurrentUserFunction } from './auth';
// ========== HELPERS ========== // ========== HELPERS ==========
/**
* Normaliza texto para busca (remove acentos, converte para lowercase)
*/
function normalizarTextoParaBusca(texto: string): string {
return texto
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // Remove diacríticos
.trim();
}
/** /**
* Gera hash simples de uma string (usando soma de caracteres como identificador único) * Gera hash simples de uma string (usando soma de caracteres como identificador único)
* Nota: Para produção, considere usar uma Action com Node.js para hash MD5/SHA256 real * Nota: Para produção, considere usar uma Action com Node.js para hash MD5/SHA256 real
@@ -34,6 +45,7 @@ function analisarFuncaoConvex(
parametros: string[]; parametros: string[];
retorno: string; retorno: string;
hash: string; hash: string;
corpoFuncao: string;
}> { }> {
const funcoes: Array<{ const funcoes: Array<{
nome: string; nome: string;
@@ -42,6 +54,7 @@ function analisarFuncaoConvex(
parametros: string[]; parametros: string[];
retorno: string; retorno: string;
hash: string; hash: string;
corpoFuncao: string;
}> = []; }> = [];
// Padrões para detectar funções // Padrões para detectar funções
@@ -49,13 +62,13 @@ function analisarFuncaoConvex(
const padraoMutation = /export\s+const\s+(\w+)\s*=\s*mutation\s*\(/g; const padraoMutation = /export\s+const\s+(\w+)\s*=\s*mutation\s*\(/g;
const padraoAction = /export\s+const\s+(\w+)\s*=\s*action\s*\(/g; const padraoAction = /export\s+const\s+(\w+)\s*=\s*action\s*\(/g;
// Extrair JSDoc comments // Extrair JSDoc comments com melhor parsing
const padraoJSDoc = /\/\*\*([\s\S]*?)\*\//g; const padraoJSDoc = /\/\*\*([\s\S]*?)\*\//g;
const jsdocs: Map<string, string> = new Map(); const jsdocs: Map<string, { descricao: string; parametros: Map<string, string>; retorno?: string }> = new Map();
let match; let match;
while ((match = padraoJSDoc.exec(conteudo)) !== null) { while ((match = padraoJSDoc.exec(conteudo)) !== null) {
const jsdoc = match[1].trim(); const jsdocRaw = match[1].trim();
// Tentar encontrar a função seguinte // Tentar encontrar a função seguinte
const proximaFuncao = conteudo.indexOf('export', match.index + match[0].length); const proximaFuncao = conteudo.indexOf('export', match.index + match[0].length);
if (proximaFuncao !== -1) { if (proximaFuncao !== -1) {
@@ -63,7 +76,32 @@ function analisarFuncaoConvex(
.substring(proximaFuncao, proximaFuncao + 200) .substring(proximaFuncao, proximaFuncao + 200)
.match(/export\s+const\s+(\w+)/); .match(/export\s+const\s+(\w+)/);
if (nomeFuncao) { if (nomeFuncao) {
jsdocs.set(nomeFuncao[1], jsdoc); // Parse JSDoc para extrair descrição, @param, @returns
const linhas = jsdocRaw.split('\n').map((l) => l.trim().replace(/^\*\s*/, ''));
let descricao = '';
const parametros = new Map<string, string>();
let retorno: string | undefined;
for (let i = 0; i < linhas.length; i++) {
const linha = linhas[i];
if (linha.startsWith('@param')) {
const paramMatch = linha.match(/@param\s+(\w+)\s+(.+)/);
if (paramMatch) {
parametros.set(paramMatch[1], paramMatch[2]);
}
} else if (linha.startsWith('@returns') || linha.startsWith('@return')) {
retorno = linha.replace(/@returns?\s+/, '');
} else if (!linha.startsWith('@') && linha.length > 0) {
if (descricao) descricao += ' ';
descricao += linha;
}
}
jsdocs.set(nomeFuncao[1], {
descricao: descricao || jsdocRaw,
parametros,
retorno
});
} }
} }
} }
@@ -76,28 +114,50 @@ function analisarFuncaoConvex(
const corpoFuncao = conteudo.substring(inicio, fim); const corpoFuncao = conteudo.substring(inicio, fim);
const hash = gerarHash(corpoFuncao); const hash = gerarHash(corpoFuncao);
// Extrair args // Extrair args com mais detalhes
const argsMatch = corpoFuncao.match(/args:\s*\{([\s\S]*?)\}/); const argsMatch = corpoFuncao.match(/args:\s*\{([\s\S]*?)\}/);
const parametros: string[] = []; const parametros: string[] = [];
const jsdocInfo = jsdocs.get(nome);
if (argsMatch) { if (argsMatch) {
const argsContent = argsMatch[1]; const argsContent = argsMatch[1];
const paramMatches = argsContent.matchAll(/(\w+):\s*v\.(\w+)/g); const paramMatches = argsContent.matchAll(/(\w+):\s*v\.(\w+)/g);
for (const paramMatch of paramMatches) { for (const paramMatch of paramMatches) {
parametros.push(`${paramMatch[1]}: ${paramMatch[2]}`); const paramNome = paramMatch[1];
const paramTipo = paramMatch[2];
const paramDesc = jsdocInfo?.parametros.get(paramNome);
if (paramDesc) {
parametros.push(`${paramNome}: ${paramTipo} - ${paramDesc}`);
} else {
parametros.push(`${paramNome}: ${paramTipo}`);
}
} }
} }
// Extrair returns // Extrair returns
const returnsMatch = corpoFuncao.match(/returns:\s*v\.(\w+)/); const returnsMatch = corpoFuncao.match(/returns:\s*v\.(\w+)/);
const retorno = returnsMatch ? returnsMatch[1] : 'void'; let retorno = returnsMatch ? returnsMatch[1] : 'void';
if (jsdocInfo?.retorno) {
retorno = `${retorno} - ${jsdocInfo.retorno}`;
}
let descricao = jsdocInfo?.descricao || `Query ${nome} do arquivo ${nomeArquivo}. Esta função realiza uma consulta no banco de dados.`;
// Adicionar análise da lógica se não houver JSDoc detalhado
if (!jsdocInfo?.descricao || jsdocInfo.descricao.length < 50) {
const logica = analisarLogicaFuncao(corpoFuncao, 'query');
if (logica) {
descricao += ' ' + logica;
}
}
funcoes.push({ funcoes.push({
nome, nome,
tipo: 'query', tipo: 'query',
descricao: jsdocs.get(nome) || `Query ${nome} do arquivo ${nomeArquivo}`, descricao,
parametros, parametros,
retorno, retorno,
hash hash,
corpoFuncao
}); });
} }
@@ -112,24 +172,46 @@ function analisarFuncaoConvex(
const argsMatch = corpoFuncao.match(/args:\s*\{([\s\S]*?)\}/); const argsMatch = corpoFuncao.match(/args:\s*\{([\s\S]*?)\}/);
const parametros: string[] = []; const parametros: string[] = [];
const jsdocInfo = jsdocs.get(nome);
if (argsMatch) { if (argsMatch) {
const argsContent = argsMatch[1]; const argsContent = argsMatch[1];
const paramMatches = argsContent.matchAll(/(\w+):\s*v\.(\w+)/g); const paramMatches = argsContent.matchAll(/(\w+):\s*v\.(\w+)/g);
for (const paramMatch of paramMatches) { for (const paramMatch of paramMatches) {
parametros.push(`${paramMatch[1]}: ${paramMatch[2]}`); const paramNome = paramMatch[1];
const paramTipo = paramMatch[2];
const paramDesc = jsdocInfo?.parametros.get(paramNome);
if (paramDesc) {
parametros.push(`${paramNome}: ${paramTipo} - ${paramDesc}`);
} else {
parametros.push(`${paramNome}: ${paramTipo}`);
}
} }
} }
const returnsMatch = corpoFuncao.match(/returns:\s*v\.(\w+)/); const returnsMatch = corpoFuncao.match(/returns:\s*v\.(\w+)/);
const retorno = returnsMatch ? returnsMatch[1] : 'void'; let retorno = returnsMatch ? returnsMatch[1] : 'void';
if (jsdocInfo?.retorno) {
retorno = `${retorno} - ${jsdocInfo.retorno}`;
}
let descricao = jsdocInfo?.descricao || `Mutation ${nome} do arquivo ${nomeArquivo}. Esta função modifica dados no banco de dados.`;
// Adicionar análise da lógica se não houver JSDoc detalhado
if (!jsdocInfo?.descricao || jsdocInfo.descricao.length < 50) {
const logica = analisarLogicaFuncao(corpoFuncao, 'mutation');
if (logica) {
descricao += ' ' + logica;
}
}
funcoes.push({ funcoes.push({
nome, nome,
tipo: 'mutation', tipo: 'mutation',
descricao: jsdocs.get(nome) || `Mutation ${nome} do arquivo ${nomeArquivo}`, descricao,
parametros, parametros,
retorno, retorno,
hash hash,
corpoFuncao
}); });
} }
@@ -144,30 +226,184 @@ function analisarFuncaoConvex(
const argsMatch = corpoFuncao.match(/args:\s*\{([\s\S]*?)\}/); const argsMatch = corpoFuncao.match(/args:\s*\{([\s\S]*?)\}/);
const parametros: string[] = []; const parametros: string[] = [];
const jsdocInfo = jsdocs.get(nome);
if (argsMatch) { if (argsMatch) {
const argsContent = argsMatch[1]; const argsContent = argsMatch[1];
const paramMatches = argsContent.matchAll(/(\w+):\s*v\.(\w+)/g); const paramMatches = argsContent.matchAll(/(\w+):\s*v\.(\w+)/g);
for (const paramMatch of paramMatches) { for (const paramMatch of paramMatches) {
parametros.push(`${paramMatch[1]}: ${paramMatch[2]}`); const paramNome = paramMatch[1];
const paramTipo = paramMatch[2];
const paramDesc = jsdocInfo?.parametros.get(paramNome);
if (paramDesc) {
parametros.push(`${paramNome}: ${paramTipo} - ${paramDesc}`);
} else {
parametros.push(`${paramNome}: ${paramTipo}`);
}
} }
} }
const returnsMatch = corpoFuncao.match(/returns:\s*v\.(\w+)/); const returnsMatch = corpoFuncao.match(/returns:\s*v\.(\w+)/);
const retorno = returnsMatch ? returnsMatch[1] : 'void'; let retorno = returnsMatch ? returnsMatch[1] : 'void';
if (jsdocInfo?.retorno) {
retorno = `${retorno} - ${jsdocInfo.retorno}`;
}
let descricao = jsdocInfo?.descricao || `Action ${nome} do arquivo ${nomeArquivo}. Esta função executa ações no servidor, podendo acessar APIs externas e realizar operações assíncronas.`;
// Adicionar análise da lógica se não houver JSDoc detalhado
if (!jsdocInfo?.descricao || jsdocInfo.descricao.length < 50) {
const logica = analisarLogicaFuncao(corpoFuncao, 'action');
if (logica) {
descricao += ' ' + logica;
}
}
funcoes.push({ funcoes.push({
nome, nome,
tipo: 'action', tipo: 'action',
descricao: jsdocs.get(nome) || `Action ${nome} do arquivo ${nomeArquivo}`, descricao,
parametros, parametros,
retorno, retorno,
hash hash,
corpoFuncao
}); });
} }
return funcoes; return funcoes;
} }
/**
* Analisa a lógica de uma função e gera uma explicação detalhada
*/
function analisarLogicaFuncao(corpoFuncao: string, tipo: 'query' | 'mutation' | 'action'): string {
const explicacoes: string[] = [];
// Detectar operações de banco de dados
const operacoesDB = {
query: corpoFuncao.match(/ctx\.db\.query\(['"]([^'"]+)['"]\)/g) || [],
insert: corpoFuncao.match(/ctx\.db\.insert\(['"]([^'"]+)['"]/g) || [],
update: corpoFuncao.match(/ctx\.db\.patch\(/g) || [],
delete: corpoFuncao.match(/ctx\.db\.delete\(/g) || [],
replace: corpoFuncao.match(/ctx\.db\.replace\(/g) || []
};
if (operacoesDB.query.length > 0) {
const tabelas = operacoesDB.query.map((q) => {
const match = q.match(/['"]([^'"]+)['"]/);
return match ? match[1] : '';
});
explicacoes.push(
`Esta função realiza consultas na(s) tabela(s): **${tabelas.filter((t) => t).join(', ')}**.`
);
}
if (operacoesDB.insert.length > 0) {
const tabelas = operacoesDB.insert.map((i) => {
const match = i.match(/['"]([^'"]+)['"]/);
return match ? match[1] : '';
});
explicacoes.push(
`A função **insere novos registros** na(s) tabela(s): **${tabelas.filter((t) => t).join(', ')}**.`
);
}
if (operacoesDB.update.length > 0) {
explicacoes.push(`A função **atualiza registros existentes** no banco de dados.`);
}
if (operacoesDB.delete.length > 0) {
explicacoes.push(`A função **remove registros** do banco de dados.`);
}
if (operacoesDB.replace.length > 0) {
explicacoes.push(`A função **substitui registros completos** no banco de dados.`);
}
// Detectar uso de índices
if (corpoFuncao.includes('.withIndex(')) {
explicacoes.push(`A função utiliza **índices do banco de dados** para otimizar as consultas.`);
}
// Detectar filtros
if (corpoFuncao.includes('.filter(')) {
explicacoes.push(`A função aplica **filtros** para restringir os resultados da consulta.`);
}
// Detectar ordenação
if (corpoFuncao.includes('.order(')) {
explicacoes.push(`A função **ordena os resultados** de acordo com critérios específicos.`);
}
// Detectar paginação
if (corpoFuncao.includes('.take(') || corpoFuncao.includes('.limit')) {
explicacoes.push(`A função implementa **paginação** para limitar a quantidade de resultados retornados.`);
}
// Detectar validações
if (corpoFuncao.includes('if (') || corpoFuncao.includes('if(')) {
const condicoes = (corpoFuncao.match(/if\s*\([^)]+\)/g) || []).length;
if (condicoes > 0) {
explicacoes.push(
`A função contém **${condicoes} condição(ões) de validação** para garantir a integridade dos dados.`
);
}
}
// Detectar loops
if (corpoFuncao.includes('for (') || corpoFuncao.includes('forEach(') || corpoFuncao.includes('.map(')) {
explicacoes.push(`A função **processa múltiplos itens** iterando sobre coleções de dados.`);
}
// Detectar chamadas a outras funções
const chamadasInternas = corpoFuncao.match(/ctx\.run(Query|Mutation|Action)\(/g) || [];
if (chamadasInternas.length > 0) {
explicacoes.push(
`A função **chama outras funções** do sistema (${chamadasInternas.length} chamada(s)) para realizar operações auxiliares.`
);
}
// Detectar tratamento de erros
if (corpoFuncao.includes('try {') || corpoFuncao.includes('catch')) {
explicacoes.push(`A função implementa **tratamento de erros** para garantir robustez.`);
}
// Detectar operações assíncronas (para actions)
if (tipo === 'action') {
if (corpoFuncao.includes('fetch(') || corpoFuncao.includes('await fetch')) {
explicacoes.push(`A função realiza **chamadas HTTP** para APIs externas.`);
}
if (corpoFuncao.includes('fs.') || corpoFuncao.includes('readFile') || corpoFuncao.includes('writeFile')) {
explicacoes.push(`A função realiza **operações de arquivo** no sistema.`);
}
}
// Detectar agregações
if (corpoFuncao.includes('.collect()')) {
explicacoes.push(`A função **coleta todos os resultados** da consulta em memória.`);
}
// Detectar contagem
if (corpoFuncao.includes('.count()') || corpoFuncao.includes('length')) {
explicacoes.push(`A função **conta elementos** ou calcula quantidades.`);
}
// Detectar transformações de dados
if (corpoFuncao.includes('.map(') || corpoFuncao.includes('.reduce(') || corpoFuncao.includes('.filter(')) {
explicacoes.push(`A função **transforma os dados** antes de retorná-los.`);
}
// Detectar autenticação/autorização
if (corpoFuncao.includes('getCurrentUser') || corpoFuncao.includes('auth') || corpoFuncao.includes('usuario')) {
explicacoes.push(`A função verifica **autenticação e autorização** do usuário.`);
}
if (explicacoes.length === 0) {
return 'A função executa operações básicas conforme sua implementação.';
}
return explicacoes.join(' ');
}
/** /**
* Gera conteúdo Markdown para uma função * Gera conteúdo Markdown para uma função
*/ */
@@ -179,27 +415,77 @@ function gerarMarkdownFuncao(
parametros: string[]; parametros: string[];
retorno: string; retorno: string;
hash: string; hash: string;
corpoFuncao: string;
}, },
arquivoOrigem: string arquivoOrigem: string
): string { ): string {
const tipoCapitalizado = funcao.tipo.charAt(0).toUpperCase() + funcao.tipo.slice(1); const tipoCapitalizado = funcao.tipo.charAt(0).toUpperCase() + funcao.tipo.slice(1);
// Descrição do tipo
let tipoDescricao = '';
switch (funcao.tipo) {
case 'query':
tipoDescricao = 'Uma **Query** é uma função de leitura que busca dados do banco de dados. Queries são otimizadas para leitura e podem ser executadas múltiplas vezes sem efeitos colaterais.';
break;
case 'mutation':
tipoDescricao = 'Uma **Mutation** é uma função de escrita que modifica dados no banco de dados. Mutations garantem consistência e podem ser executadas de forma transacional.';
break;
case 'action':
tipoDescricao = 'Uma **Action** é uma função que pode executar operações no servidor, incluindo chamadas a APIs externas, operações de arquivo e outras tarefas assíncronas.';
break;
}
let markdown = `# ${funcao.nome}\n\n`; let markdown = `# ${funcao.nome}\n\n`;
markdown += `## Tipo\n\n`; markdown += `## Visão Geral\n\n`;
markdown += `${tipoCapitalizado}\n\n`;
markdown += `## Descrição\n\n`;
markdown += `${funcao.descricao}\n\n`; markdown += `${funcao.descricao}\n\n`;
if (funcao.parametros.length > 0) { markdown += `## Tipo de Função\n\n`;
markdown += `## Parâmetros\n\n`; markdown += `**${tipoCapitalizado}**\n\n`;
for (const param of funcao.parametros) { markdown += `${tipoDescricao}\n\n`;
markdown += `- \`${param}\`\n`;
} // Adicionar seção de lógica implementada
markdown += `\n`; const logicaDetalhada = analisarLogicaFuncao(funcao.corpoFuncao, funcao.tipo);
if (logicaDetalhada && logicaDetalhada !== 'A função executa operações básicas conforme sua implementação.') {
markdown += `## Lógica Implementada\n\n`;
markdown += `${logicaDetalhada}\n\n`;
} }
markdown += `## Retorno\n\n`; if (funcao.parametros.length > 0) {
markdown += `\`${funcao.retorno}\`\n\n`; markdown += `## Parâmetros de Entrada\n\n`;
markdown += `Esta função aceita os seguintes parâmetros:\n\n`;
for (const param of funcao.parametros) {
// Separar nome, tipo e descrição se houver
const paramMatch = param.match(/^(.+?):\s*(.+?)(?:\s*-\s*(.+))?$/);
if (paramMatch) {
const [, nome, tipo, desc] = paramMatch;
markdown += `### \`${nome}\`\n\n`;
markdown += `- **Tipo:** \`${tipo}\`\n`;
if (desc) {
markdown += `- **Descrição:** ${desc}\n`;
}
markdown += `\n`;
} else {
markdown += `- \`${param}\`\n`;
}
}
markdown += `\n`;
} else {
markdown += `## Parâmetros de Entrada\n\n`;
markdown += `Esta função não requer parâmetros.\n\n`;
}
markdown += `## Valor de Retorno\n\n`;
const retornoMatch = funcao.retorno.match(/^(.+?)(?:\s*-\s*(.+))?$/);
if (retornoMatch) {
const [, tipo, desc] = retornoMatch;
markdown += `- **Tipo:** \`${tipo}\`\n`;
if (desc) {
markdown += `- **Descrição:** ${desc}\n`;
}
} else {
markdown += `\`${funcao.retorno}\`\n`;
}
markdown += `\n`;
markdown += `## Arquivo Origem\n\n`; markdown += `## Arquivo Origem\n\n`;
markdown += `\`${arquivoOrigem}\`\n\n`; markdown += `\`${arquivoOrigem}\`\n\n`;
@@ -286,44 +572,39 @@ export const executarVarredura = internalMutation({
} }
} }
// Por enquanto, vamos apenas atualizar o status da varredura // Chamar Action para ler e analisar arquivos do sistema de arquivos
// A análise real de arquivos precisaria ser feita via Action que lê o sistema de arquivos // Usar scheduler para não bloquear, mas a Action atualizará a varredura quando terminar
// ou através de um processo externo que envia os dados para o Convex console.log(`🚀 [executarVarredura] Iniciando análise de ${arquivosConvex.length} arquivos`);
ctx.scheduler.runAfter(
0,
api.actions.documentacaoVarredura.analisarArquivos,
{
varreduraId,
arquivos: arquivosConvex,
executadoPor: args.executadoPor
}
).catch((error) => {
console.error('❌ [executarVarredura] Erro ao agendar Action:', error);
erros.push(`Erro ao agendar análise: ${error instanceof Error ? error.message : 'Erro desconhecido'}`);
});
// Atualizar status inicial (a Action atualizará quando terminar)
arquivosAnalisados = arquivosConvex.length; arquivosAnalisados = arquivosConvex.length;
// Atualizar status da varredura
const duracao = Date.now() - inicio; const duracao = Date.now() - inicio;
await ctx.db.patch(varreduraId, { await ctx.db.patch(varreduraId, {
status: 'concluida', status: 'em_andamento', // Manter como em_andamento até a Action terminar
documentosEncontrados: documentosExistentes.length, documentosEncontrados: documentosExistentes.length,
documentosNovos, documentosNovos: 0,
documentosAtualizados, documentosAtualizados: 0,
arquivosAnalisados, arquivosAnalisados,
erros: erros.length > 0 ? erros : undefined, erros: erros.length > 0 ? erros : undefined,
duracaoMs: duracao, duracaoMs: duracao
concluidoEm: Date.now()
}); });
// Notificar TI_master se houver novos documentos console.log(`✅ [executarVarredura] Varredura agendada, ID: ${varreduraId}`);
if (documentosNovos > 0) {
// Buscar IDs dos novos documentos criados durante esta varredura
const novosDocumentos = await ctx.db
.query('documentacao')
.filter((q) => q.eq(q.field('geradoAutomaticamente'), true))
.filter((q) => q.gte(q.field('criadoEm'), inicio))
.collect();
const novosDocumentosIds = novosDocumentos.map((doc) => doc._id); // A notificação será feita pela Action quando terminar
if (novosDocumentosIds.length > 0) {
// Notificar via scheduler para não bloquear
await ctx.scheduler.runAfter(0, api.documentacao.notificarNovosDocumentos, {
documentosIds: novosDocumentosIds,
quantidade: documentosNovos
});
}
}
return varreduraId; return varreduraId;
} catch (error) { } catch (error) {
@@ -342,6 +623,195 @@ export const executarVarredura = internalMutation({
} }
}); });
/**
* Atualizar varredura com resultados da Action
*/
export const atualizarVarreduraComResultados = internalMutation({
args: {
varreduraId: v.id('documentacaoVarredura'),
documentosNovos: v.number(),
documentosAtualizados: v.number(),
arquivosAnalisados: v.number(),
erros: v.array(v.string())
},
handler: async (ctx, args) => {
const varredura = await ctx.db.get(args.varreduraId);
if (!varredura) return;
const duracao = Date.now() - varredura.iniciadoEm;
// Buscar documentos encontrados
const documentosEncontrados = await ctx.db
.query('documentacao')
.filter((q) => q.eq(q.field('geradoAutomaticamente'), true))
.collect();
await ctx.db.patch(args.varreduraId, {
status: 'concluida',
documentosEncontrados: documentosEncontrados.length,
documentosNovos: args.documentosNovos,
documentosAtualizados: args.documentosAtualizados,
arquivosAnalisados: args.arquivosAnalisados,
erros: args.erros.length > 0 ? args.erros : undefined,
duracaoMs: duracao,
concluidoEm: Date.now()
});
// Notificar TI_master se houver novos documentos
if (args.documentosNovos > 0) {
// Buscar IDs dos novos documentos criados durante esta varredura
const novosDocumentos = await ctx.db
.query('documentacao')
.filter((q) => q.eq(q.field('geradoAutomaticamente'), true))
.filter((q) => q.gte(q.field('criadoEm'), varredura.iniciadoEm))
.collect();
const novosDocumentosIds = novosDocumentos.map((doc) => doc._id);
if (novosDocumentosIds.length > 0) {
// Notificar via scheduler para não bloquear
await ctx.scheduler.runAfter(0, api.documentacao.notificarNovosDocumentos, {
documentosIds: novosDocumentosIds,
quantidade: args.documentosNovos
});
}
}
}
});
/**
* Processar um arquivo e criar/atualizar documentos (chamado pela Action)
*/
export const processarArquivo = internalMutation({
args: {
varreduraId: v.id('documentacaoVarredura'),
nomeArquivo: v.string(),
conteudo: v.string(),
executadoPor: v.id('usuarios')
},
returns: v.object({
documentosNovos: v.number(),
documentosAtualizados: v.number()
}),
handler: async (ctx, args) => {
let documentosNovos = 0;
let documentosAtualizados = 0;
console.log(`📝 [processarArquivo] Processando arquivo: ${args.nomeArquivo}`);
try {
// Analisar funções no arquivo
const funcoes = analisarFuncaoConvex(args.conteudo, args.nomeArquivo);
console.log(`🔍 [processarArquivo] Encontradas ${funcoes.length} funções em ${args.nomeArquivo}`);
if (funcoes.length === 0) {
console.log(`⚠️ [processarArquivo] Nenhuma função encontrada em ${args.nomeArquivo}`);
}
// Processar cada função encontrada
for (const funcao of funcoes) {
console.log(` 📌 Processando função: ${funcao.nome} (${funcao.tipo})`);
const markdown = gerarMarkdownFuncao(funcao, args.nomeArquivo);
const hashOrigem = `${args.nomeArquivo}:${funcao.hash}`;
// Verificar se já existe documento com este hash
const documentoExistente = await ctx.db
.query('documentacao')
.withIndex('by_hash_origem', (q) => q.eq('hashOrigem', hashOrigem))
.first();
const conteudoBusca = normalizarTextoParaBusca(
`${funcao.nome} ${funcao.descricao} ${markdown}`
);
if (documentoExistente) {
// Verificar se houve mudança no conteúdo (comparar hash)
const hashAtual = funcao.hash;
const hashAnterior = documentoExistente.hashOrigem?.split(':')[1] || '';
// Calcular nova versão se houver mudança
let novaVersao = documentoExistente.versao;
if (hashAtual !== hashAnterior) {
// Incrementar versão (ex: 1.0.0 -> 1.0.1, 1.0.1 -> 1.0.2, etc)
const partes = documentoExistente.versao.split('.');
const patch = parseInt(partes[2] || '0', 10) + 1;
novaVersao = `${partes[0]}.${partes[1]}.${patch}`;
console.log(
` 🔄 Função ${funcao.nome} alterada: versão ${documentoExistente.versao} -> ${novaVersao}`
);
}
// Atualizar documento existente
await ctx.db.patch(documentoExistente._id, {
titulo: `${funcao.nome} (${funcao.tipo})`,
conteudo: markdown,
conteudoBusca,
versao: novaVersao,
metadados: {
parametros: funcao.parametros,
retorno: funcao.retorno,
algoritmo: funcao.descricao
},
atualizadoEm: Date.now()
});
console.log(` ✅ Documento atualizado: ${funcao.nome} (versão ${novaVersao})`);
documentosAtualizados++;
} else {
// Criar novo documento
const agora = Date.now();
// Determinar categoria baseada no tipo
let categoriaId: Id<'documentacaoCategorias'> | undefined;
const categoriaBackend = await ctx.db
.query('documentacaoCategorias')
.filter((q) => q.eq(q.field('nome'), 'Backend'))
.first();
if (categoriaBackend) {
categoriaId = categoriaBackend._id;
}
const documentoId = await ctx.db.insert('documentacao', {
titulo: `${funcao.nome} (${funcao.tipo})`,
conteudo: markdown,
conteudoBusca,
categoriaId,
tags: [funcao.tipo, 'automatico'],
tipo: funcao.tipo,
versao: '1.0.0',
arquivoOrigem: args.nomeArquivo,
funcaoOrigem: funcao.nome,
hashOrigem: hashOrigem,
metadados: {
parametros: funcao.parametros,
retorno: funcao.retorno,
algoritmo: funcao.descricao
},
ativo: true,
criadoPor: args.executadoPor,
criadoEm: agora,
atualizadoEm: agora,
visualizacoes: 0,
geradoAutomaticamente: true
});
console.log(` ✨ Novo documento criado: ${funcao.nome} (ID: ${documentoId})`);
documentosNovos++;
}
}
} catch (error) {
const erroMsg = error instanceof Error ? error.message : 'Erro desconhecido';
console.error(`❌ [processarArquivo] Erro ao processar arquivo ${args.nomeArquivo}:`, erroMsg);
throw error;
}
console.log(
`✅ [processarArquivo] Arquivo ${args.nomeArquivo} processado: ${documentosNovos} novos, ${documentosAtualizados} atualizados`
);
return { documentosNovos, documentosAtualizados };
}
});
/** /**
* Criar documento a partir de função detectada * Criar documento a partir de função detectada
*/ */
@@ -469,7 +939,7 @@ export const executarVarreduraManual = mutation({
/** /**
* Obter histórico de varreduras * Obter histórico de varreduras
*/ */
export const obterHistoricoVarreduras = mutation({ export const obterHistoricoVarreduras = query({
args: { args: {
limite: v.optional(v.number()) limite: v.optional(v.number())
}, },
@@ -492,10 +962,56 @@ export const obterHistoricoVarreduras = mutation({
.order('desc') .order('desc')
.take(limite); .take(limite);
// Enriquecer com informações do usuário // Enriquecer com informações do usuário e arquivos modificados
const varredurasEnriquecidas = await Promise.all( const varredurasEnriquecidas = await Promise.all(
varreduras.map(async (varredura) => { varreduras.map(async (varredura) => {
const usuario = await ctx.db.get(varredura.executadoPor); const usuario = await ctx.db.get(varredura.executadoPor);
// Buscar documentos atualizados durante esta varredura
const documentosAtualizados = await ctx.db
.query('documentacao')
.filter((q) => q.eq(q.field('geradoAutomaticamente'), true))
.filter((q) => q.gte(q.field('atualizadoEm'), varredura.iniciadoEm))
.filter((q) => q.lte(q.field('atualizadoEm'), varredura.concluidoEm || Date.now()))
.collect();
// Agrupar por arquivo e obter versões
const arquivosModificados = new Map<string, { versao: string; funcoes: string[] }>();
for (const doc of documentosAtualizados) {
if (doc.arquivoOrigem) {
const arquivo = doc.arquivoOrigem;
if (!arquivosModificados.has(arquivo)) {
arquivosModificados.set(arquivo, { versao: doc.versao, funcoes: [] });
}
const info = arquivosModificados.get(arquivo)!;
if (doc.funcaoOrigem) {
info.funcoes.push(doc.funcaoOrigem);
}
// Manter a versão mais recente (comparar numericamente)
const partesAtual = doc.versao.split('.').map(Number);
const partesInfo = info.versao.split('.').map(Number);
let versaoMaisNova = info.versao;
for (let i = 0; i < Math.max(partesAtual.length, partesInfo.length); i++) {
const atual = partesAtual[i] || 0;
const infoParte = partesInfo[i] || 0;
if (atual > infoParte) {
versaoMaisNova = doc.versao;
break;
} else if (atual < infoParte) {
break;
}
}
info.versao = versaoMaisNova;
}
}
const arquivosModificadosArray = Array.from(arquivosModificados.entries()).map(([arquivo, info]) => ({
arquivo,
versao: info.versao,
funcoes: info.funcoes
}));
return { return {
...varredura, ...varredura,
executadoPorUsuario: usuario executadoPorUsuario: usuario
@@ -504,7 +1020,9 @@ export const obterHistoricoVarreduras = mutation({
nome: usuario.nome, nome: usuario.nome,
email: usuario.email email: usuario.email
} }
: null : null,
arquivosModificados: arquivosModificadosArray,
totalArquivosModificados: arquivosModificados.size
}; };
}) })
); );