refactor: remove documentation components and related routes to streamline the application structure and improve maintainability

This commit is contained in:
2025-12-07 16:17:20 -03:00
parent 10a729baed
commit 12984997ce
16 changed files with 1 additions and 4555 deletions

View File

@@ -1,167 +0,0 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { FileText, Calendar, Tag, CheckCircle2, Circle, Eye } from 'lucide-svelte';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
type TipoDocumento =
| 'query'
| 'mutation'
| 'action'
| 'component'
| 'route'
| 'modulo'
| 'manual'
| 'outro';
type Documento = {
_id: Id<'documentacao'>;
titulo: string;
conteudo: string;
conteudoHtml?: string;
categoriaId?: Id<'documentacaoCategorias'>;
tags: string[];
tipo: TipoDocumento;
versao: string;
ativo: boolean;
visualizacoes: number;
geradoAutomaticamente: boolean;
atualizadoEm: number;
categoria?: Doc<'documentacaoCategorias'> | null;
};
interface Props {
documento: Documento;
selecionado?: boolean;
onToggleSelecao?: () => void;
onVisualizar?: () => void;
}
let { documento, selecionado = false, onToggleSelecao, onVisualizar }: Props = $props();
const tipoLabels: Record<string, string> = {
query: 'Query',
mutation: 'Mutation',
action: 'Action',
component: 'Componente',
route: 'Rota',
modulo: 'Módulo',
manual: 'Manual',
outro: 'Outro'
};
const tipoColors: Record<string, string> = {
query: 'badge-info',
mutation: 'badge-warning',
action: 'badge-error',
component: 'badge-success',
route: 'badge-primary',
modulo: 'badge-secondary',
manual: 'badge-accent',
outro: 'badge-ghost'
};
</script>
<article
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={() => {
// Se não houver seleção habilitada, redireciona para a página do documento
if (!onToggleSelecao) {
window.location.href = resolve(`/ti/documentacao/${documento._id}`);
}
// Se houver seleção, não faz nada no clique do card (apenas o checkbox seleciona)
}}
>
<!-- Checkbox de seleção -->
{#if onToggleSelecao}
<button
class="absolute right-4 top-4 z-20 cursor-pointer"
onclick={(e) => {
e.stopPropagation();
e.preventDefault();
onToggleSelecao();
}}
type="button"
>
{#if selecionado}
<CheckCircle2 class="text-primary h-6 w-6" />
{:else}
<Circle class="text-base-content/30 h-6 w-6" />
{/if}
</button>
{/if}
<!-- Header -->
<div class="flex items-start gap-4">
<div class="bg-primary/15 text-primary flex h-12 w-12 shrink-0 items-center justify-center rounded-xl">
<FileText class="h-6 w-6" />
</div>
<div class="min-w-0 flex-1">
<h3 class="text-base-content mb-1 text-lg font-semibold line-clamp-2">
{documento.titulo}
</h3>
<div class="flex flex-wrap items-center gap-2">
<span class="badge badge-sm {tipoColors[documento.tipo] || 'badge-ghost'}">
{tipoLabels[documento.tipo] || documento.tipo}
</span>
{#if documento.geradoAutomaticamente}
<span class="badge badge-sm badge-outline">Auto</span>
{/if}
</div>
</div>
</div>
<!-- Descrição -->
{#if documento.conteudo}
<p class="text-base-content/70 line-clamp-3 text-sm">
{documento.conteudo.substring(0, 150)}...
</p>
{/if}
<!-- Tags -->
{#if documento.tags && documento.tags.length > 0}
<div class="flex flex-wrap items-center gap-2">
<Tag class="text-base-content/50 h-4 w-4" />
{#each documento.tags.slice(0, 3) as tag}
<span class="badge badge-xs badge-outline">{tag}</span>
{/each}
{#if documento.tags.length > 3}
<span class="text-base-content/50 text-xs">+{documento.tags.length - 3}</span>
{/if}
</div>
{/if}
<!-- Footer -->
<div class="flex items-center justify-between border-t border-base-300 pt-4">
<div class="flex items-center gap-2 text-xs text-base-content/50">
<Calendar class="h-4 w-4" />
<span>
{documento.atualizadoEm && !isNaN(new Date(documento.atualizadoEm).getTime())
? format(new Date(documento.atualizadoEm), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })
: 'Data não disponível'}
</span>
</div>
<div class="flex items-center gap-3">
{#if documento.visualizacoes !== undefined}
<span class="text-base-content/50 text-xs">{documento.visualizacoes} visualizações</span>
{/if}
{#if onVisualizar}
<button
class="btn btn-ghost btn-xs gap-1"
onclick={(e) => {
e.stopPropagation();
e.preventDefault();
onVisualizar();
}}
title="Visualizar documento"
type="button"
>
<Eye class="h-4 w-4" />
Visualizar
</button>
{/if}
</div>
</div>
</article>

View File

@@ -1,322 +0,0 @@
<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

@@ -1,31 +0,0 @@
<script lang="ts">
import { Search, X } from 'lucide-svelte';
interface Props {
busca: string;
mostrarFiltros: boolean;
}
let { busca = $bindable(''), mostrarFiltros = $bindable(false) }: Props = $props();
</script>
<div class="relative flex-1">
<div class="relative">
<Search class="text-base-content/50 absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2" />
<input
type="text"
placeholder="Buscar documentos..."
class="input input-bordered w-full pl-12 pr-10"
bind:value={busca}
/>
{#if busca}
<button
class="text-base-content/50 absolute right-4 top-1/2 -translate-y-1/2 hover:text-base-content"
onclick={() => (busca = '')}
>
<X class="h-5 w-5" />
</button>
{/if}
</div>
</div>

View File

@@ -1,133 +0,0 @@
<script lang="ts">
import { Folder, Tag, Filter } from 'lucide-svelte';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
type TipoDocumento =
| 'query'
| 'mutation'
| 'action'
| 'component'
| 'route'
| 'modulo'
| 'manual'
| 'outro';
interface Props {
categoriaSelecionada: Id<'documentacaoCategorias'> | null;
tipoSelecionado: TipoDocumento | null;
tagsSelecionadas: string[];
categorias: Doc<'documentacaoCategorias'>[];
tags: Doc<'documentacaoTags'>[];
}
let {
categoriaSelecionada = $bindable(null),
tipoSelecionado = $bindable(null),
tagsSelecionadas = $bindable([]),
categorias = [],
tags = []
}: Props = $props();
const tipos: Array<{ value: TipoDocumento; label: string }> = [
{ value: 'query', label: 'Query' },
{ value: 'mutation', label: 'Mutation' },
{ value: 'action', label: 'Action' },
{ value: 'component', label: 'Componente' },
{ value: 'route', label: 'Rota' },
{ value: 'modulo', label: 'Módulo' },
{ value: 'manual', label: 'Manual' },
{ value: 'outro', label: 'Outro' }
];
function toggleTag(tagNome: string) {
if (tagsSelecionadas.includes(tagNome)) {
tagsSelecionadas = tagsSelecionadas.filter((t) => t !== tagNome);
} else {
tagsSelecionadas = [...tagsSelecionadas, tagNome];
}
}
</script>
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
<!-- Categorias -->
<div>
<h3 class="text-base-content mb-3 flex items-center gap-2 font-semibold">
<Folder class="h-5 w-5" />
Categorias
</h3>
<div class="space-y-2">
{#each categorias as categoria}
<label class="flex cursor-pointer items-center gap-2">
<input
type="radio"
name="categoria"
checked={categoriaSelecionada === categoria._id}
onchange={() => {
categoriaSelecionada = categoriaSelecionada === categoria._id ? null : categoria._id;
}}
class="radio radio-sm"
/>
<span class="text-sm">{categoria.nome}</span>
</label>
{/each}
{#if categorias.length === 0}
<p class="text-base-content/50 text-sm">Nenhuma categoria disponível</p>
{/if}
</div>
</div>
<!-- Tipos -->
<div>
<h3 class="text-base-content mb-3 flex items-center gap-2 font-semibold">
<Filter class="h-5 w-5" />
Tipos
</h3>
<div class="space-y-2">
{#each tipos as tipo}
<label class="flex cursor-pointer items-center gap-2">
<input
type="radio"
name="tipo"
checked={tipoSelecionado === tipo.value}
onchange={() => {
tipoSelecionado = tipoSelecionado === tipo.value ? null : tipo.value;
}}
class="radio radio-sm"
/>
<span class="text-sm">{tipo.label}</span>
</label>
{/each}
</div>
</div>
<!-- Tags -->
<div>
<h3 class="text-base-content mb-3 flex items-center gap-2 font-semibold">
<Tag class="h-5 w-5" />
Tags
</h3>
<div class="flex flex-wrap gap-2">
{#each tags.slice(0, 20) as tag}
<label class="cursor-pointer">
<input
type="checkbox"
checked={tagsSelecionadas.includes(tag.nome)}
onchange={() => toggleTag(tag.nome)}
class="checkbox checkbox-sm hidden"
/>
<span
class="badge {tagsSelecionadas.includes(tag.nome)
? 'badge-primary'
: 'badge-outline'} cursor-pointer"
>
{tag.nome}
</span>
</label>
{/each}
{#if tags.length === 0}
<p class="text-base-content/50 text-sm">Nenhuma tag disponível</p>
{/if}
</div>
</div>
</div>

View File

@@ -1,306 +0,0 @@
<script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import { marked } from 'marked';
import { X, Download, Loader2 } from 'lucide-svelte';
interface Props {
documentosIds: Id<'documentacao'>[];
onClose: () => void;
}
let { documentosIds, onClose }: Props = $props();
const client = useConvexClient();
let gerando = $state(false);
async function gerarPDF() {
try {
gerando = true;
// Buscar documentos
const documentos = await client.query(api.documentacao.obterDocumentosPorIds, {
documentosIds
});
if (!documentos || documentos.length === 0) {
alert('Nenhum documento encontrado');
return;
}
const doc = new jsPDF();
const pageHeight = doc.internal.pageSize.getHeight();
const pageWidth = doc.internal.pageSize.getWidth();
const margin = 20;
const primaryColor = [102, 126, 234];
const secondaryColor = [128, 128, 128];
// Cabeçalho
doc.setFillColor(...primaryColor);
doc.rect(0, 0, pageWidth, 40, 'F');
doc.setTextColor(255, 255, 255);
doc.setFontSize(22);
doc.setFont('helvetica', 'bold');
doc.text('Biblioteca de Documentação SGSE', pageWidth / 2, 25, { align: 'center' });
let yPos = 50;
// Informações de geração
doc.setTextColor(0, 0, 0);
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.text(`Gerado em: ${new Date().toLocaleString('pt-BR')}`, margin, yPos);
yPos += 6;
doc.text(`Total de documentos: ${documentos.length}`, margin, yPos);
yPos += 15;
// Índice com tabela
doc.setFontSize(16);
doc.setTextColor(...primaryColor);
doc.setFont('helvetica', 'bold');
doc.text('Índice', margin, yPos);
yPos += 10;
const indiceData = documentos.map((doc, index) => [
(index + 1).toString(),
doc.titulo,
doc.tipo,
doc.versao
]);
autoTable(doc, {
startY: yPos,
head: [['#', 'Título', 'Tipo', 'Versão']],
body: indiceData,
theme: 'striped',
headStyles: {
fillColor: primaryColor,
textColor: [255, 255, 255],
fontStyle: 'bold'
},
styles: {
fontSize: 9,
cellPadding: 3
},
columnStyles: {
0: { cellWidth: 15 },
1: { cellWidth: 'auto' },
2: { cellWidth: 40 },
3: { cellWidth: 30 }
}
});
yPos = (doc as any).lastAutoTable.finalY + 15;
// Documentos
for (let i = 0; i < documentos.length; i++) {
const documento = documentos[i];
// Nova página para cada documento (exceto o primeiro)
if (i > 0) {
doc.addPage();
yPos = margin;
} else if (yPos > pageHeight - 50) {
doc.addPage();
yPos = margin;
}
// Cabeçalho do documento
doc.setFillColor(...primaryColor);
doc.rect(0, yPos - 10, pageWidth, 15, 'F');
doc.setTextColor(255, 255, 255);
doc.setFontSize(16);
doc.setFont('helvetica', 'bold');
doc.text(`${i + 1}. ${documento.titulo}`, margin, yPos + 3, { maxWidth: pageWidth - 2 * margin });
yPos += 20;
// Metadados em tabela
autoTable(doc, {
startY: yPos,
body: [
['Tipo', documento.tipo],
['Versão', documento.versao],
['Arquivo Origem', documento.arquivoOrigem || 'N/A']
],
theme: 'plain',
styles: {
fontSize: 9,
cellPadding: 4
},
columnStyles: {
0: { fontStyle: 'bold', cellWidth: 50, fillColor: [240, 240, 240] },
1: { cellWidth: 'auto' }
}
});
yPos = (doc as any).lastAutoTable.finalY + 10;
// Conteúdo
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
doc.setFont('helvetica', 'normal');
// Processar Markdown de forma mais inteligente
let conteudoTexto = documento.conteudo;
// Processar seções
const secoes = conteudoTexto.split(/\n##\s+/);
for (let j = 0; j < secoes.length; j++) {
let secao = secoes[j];
if (j === 0) {
// Primeira seção (antes do primeiro ##)
secao = secao.replace(/^#\s+.*?\n\n/, ''); // Remove título principal
}
// Detectar título da seção
const tituloMatch = secao.match(/^(.+?)\n/);
if (tituloMatch && j > 0) {
// Adicionar título da seção
if (yPos > pageHeight - 30) {
doc.addPage();
yPos = margin;
}
doc.setFontSize(12);
doc.setTextColor(...primaryColor);
doc.setFont('helvetica', 'bold');
doc.text(tituloMatch[1], margin, yPos);
yPos += 8;
secao = secao.substring(tituloMatch[0].length);
}
// Processar conteúdo da seção
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
doc.setFont('helvetica', 'normal');
// Remover markdown básico
secao = secao.replace(/\*\*(.*?)\*\*/g, '$1'); // Bold
secao = secao.replace(/\*(.*?)\*/g, '$1'); // Italic
secao = secao.replace(/`(.*?)`/g, '$1'); // Code inline
secao = secao.replace(/\[(.*?)\]\(.*?\)/g, '$1'); // Links
// Processar listas
const linhas = secao.split('\n');
for (const linha of linhas) {
if (yPos > pageHeight - 20) {
doc.addPage();
yPos = margin;
}
if (linha.trim().startsWith('-') || linha.trim().startsWith('*')) {
// Item de lista
const texto = linha.replace(/^[\s\-*]+/, '• ');
const linhasTexto = doc.splitTextToSize(texto, pageWidth - 2 * margin - 10);
doc.text(linhasTexto[0], margin + 5, yPos);
yPos += 5;
for (let k = 1; k < linhasTexto.length; k++) {
if (yPos > pageHeight - 20) {
doc.addPage();
yPos = margin;
}
doc.text(linhasTexto[k], margin + 10, yPos);
yPos += 5;
}
} else if (linha.trim().startsWith('###')) {
// Subtítulo
const subtitulo = linha.replace(/^###\s+/, '');
doc.setFontSize(11);
doc.setTextColor(...primaryColor);
doc.setFont('helvetica', 'bold');
doc.text(subtitulo, margin + 5, yPos);
yPos += 7;
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
doc.setFont('helvetica', 'normal');
} else if (linha.trim()) {
// Texto normal
const linhasTexto = doc.splitTextToSize(linha.trim(), pageWidth - 2 * margin);
for (const linhaTexto of linhasTexto) {
if (yPos > pageHeight - 20) {
doc.addPage();
yPos = margin;
}
doc.text(linhaTexto, margin, yPos);
yPos += 5;
}
} else {
// Linha vazia
yPos += 3;
}
}
yPos += 5;
}
}
// Footer em todas as páginas
const pageCount = doc.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i);
doc.setFillColor(240, 240, 240);
doc.rect(0, pageHeight - 15, pageWidth, 15, 'F');
doc.setFontSize(8);
doc.setTextColor(...secondaryColor);
doc.setFont('helvetica', 'normal');
doc.text(
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
pageWidth / 2,
pageHeight - 7,
{ align: 'center' }
);
}
// Salvar
doc.save(`documentacao-sgse-${new Date().toISOString().split('T')[0]}.pdf`);
onClose();
} catch (error) {
console.error('Erro ao gerar PDF:', error);
alert('Erro ao gerar PDF. Tente novamente.');
} finally {
gerando = false;
}
}
</script>
<div class="modal modal-open">
<div class="modal-box max-w-2xl">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-2xl font-bold">Gerar PDF</h2>
<button class="btn btn-circle btn-ghost btn-sm" onclick={onClose}>
<X class="h-5 w-5" />
</button>
</div>
<div class="space-y-4">
<p class="text-base-content/70">
Você selecionou <strong>{documentosIds.length}</strong> documento(s) para gerar PDF.
</p>
<div class="bg-base-200 rounded-lg p-4">
<p class="text-base-content/70 text-sm">
O PDF será gerado com todos os documentos selecionados, incluindo um índice e formatação
apropriada.
</p>
</div>
<div class="flex justify-end gap-3">
<button class="btn btn-ghost" onclick={onClose} disabled={gerando}>Cancelar</button>
<button class="btn btn-primary gap-2" onclick={gerarPDF} disabled={gerando}>
{#if gerando}
<Loader2 class="h-5 w-5 animate-spin" />
Gerando...
{:else}
<Download class="h-5 w-5" />
Gerar PDF
{/if}
</button>
</div>
</div>
</div>
<div class="modal-backdrop" onclick={onClose}></div>
</div>

View File

@@ -427,16 +427,6 @@
href: '/(dashboard)/ti/configuracoes',
palette: 'secondary',
icon: 'control'
},
{
title: 'Documentação',
description:
'Manuais, guias e documentação técnica do sistema para usuários e administradores.',
ctaLabel: 'Acessar Biblioteca',
href: '/(dashboard)/ti/documentacao',
palette: 'primary',
icon: 'document',
disabled: false
}
];
</script>

View File

@@ -1,232 +0,0 @@
<script lang="ts">
import { resolve } from '$app/paths';
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 ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
import { Filter, Settings, Download, BookOpen } from 'lucide-svelte';
import DocumentacaoCard from '$lib/components/documentacao/DocumentacaoCard.svelte';
import DocumentacaoSearch from '$lib/components/documentacao/DocumentacaoSearch.svelte';
import DocumentacaoSidebar from '$lib/components/documentacao/DocumentacaoSidebar.svelte';
import PdfGenerator from '$lib/components/documentacao/PdfGenerator.svelte';
import DocumentacaoModal from '$lib/components/documentacao/DocumentacaoModal.svelte';
// Estados
let busca = $state('');
let categoriaSelecionada = $state<Id<'documentacaoCategorias'> | null>(null);
let tipoSelecionado = $state<
'query' | 'mutation' | 'action' | 'component' | 'route' | 'modulo' | 'manual' | 'outro' | null
>(null);
let tagsSelecionadas = $state<string[]>([]);
let mostrarFiltros = $state(false);
let mostrarPdfGenerator = $state(false);
let documentoModalId = $state<Id<'documentacao'> | null>(null);
let documentosSelecionados = $state<Id<'documentacao'>[]>([]);
// Queries - usar funções para garantir reatividade
const documentosQuery = useQuery(api.documentacao.listarDocumentos, () => ({
categoriaId: categoriaSelecionada || undefined,
tipo: tipoSelecionado || undefined,
tags: tagsSelecionadas.length > 0 ? tagsSelecionadas : undefined,
busca: busca.trim() || undefined,
ativo: true,
limite: 50
}));
const categoriasQuery = useQuery(api.documentacao.listarCategorias, () => ({
ativo: true
}));
const tagsQuery = useQuery(api.documentacao.listarTags, () => ({
ativo: true,
limite: 50
}));
// Dados derivados
const documentos = $derived.by(() => {
if (!documentosQuery || !documentosQuery.data) return [];
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(() => {
if (!categoriasQuery || !categoriasQuery.data) return [];
return Array.isArray(categoriasQuery.data) ? categoriasQuery.data : [];
});
const tags = $derived.by(() => {
if (!tagsQuery || !tagsQuery.data) return [];
return Array.isArray(tagsQuery.data) ? tagsQuery.data : [];
});
// Funções
function limparFiltros() {
busca = '';
categoriaSelecionada = null;
tipoSelecionado = null;
tagsSelecionadas = [];
}
function toggleSelecaoDocumento(documentoId: Id<'documentacao'>) {
if (documentosSelecionados.includes(documentoId)) {
documentosSelecionados = documentosSelecionados.filter((id) => id !== documentoId);
} else {
documentosSelecionados = [...documentosSelecionados, documentoId];
}
}
function abrirPdfGenerator() {
if (documentosSelecionados.length === 0) {
alert('Selecione pelo menos um documento para gerar PDF');
return;
}
mostrarPdfGenerator = true;
}
function abrirModal(documentoId: Id<'documentacao'>) {
documentoModalId = documentoId;
}
function fecharModal() {
documentoModalId = null;
}
</script>
<ProtectedRoute allowedRoles={['ti_master', 'ti_usuario', 'admin']} maxLevel={1}>
<main class="mx-auto w-full max-w-7xl space-y-6 px-4 py-8">
<!-- Header -->
<section
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-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="max-w-3xl space-y-4">
<span
class="border-primary/40 bg-primary/10 text-primary inline-flex w-fit items-center gap-2 rounded-full border px-4 py-1 text-xs font-semibold tracking-[0.28em] uppercase"
>
Biblioteca de Documentação
</span>
<h1 class="text-base-content text-4xl leading-tight font-black sm:text-5xl">
Documentação Técnica do SGSE
</h1>
<p class="text-base-content/70 text-base leading-relaxed sm:text-lg">
Biblioteca completa com todas as funcionalidades, recursos, manuais técnicos e
explicações detalhadas dos algoritmos do sistema.
</p>
</div>
<div class="flex gap-3">
<a href={resolve('/ti/documentacao/configuracao')} class="btn btn-primary gap-2">
<Settings class="h-5 w-5" />
Configuração
</a>
{#if documentosSelecionados.length > 0}
<button class="btn btn-success gap-2" onclick={abrirPdfGenerator}>
<Download class="h-5 w-5" />
Gerar PDF ({documentosSelecionados.length})
</button>
{/if}
</div>
</div>
</section>
<!-- Busca e Filtros -->
<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">
<DocumentacaoSearch bind:busca bind:mostrarFiltros />
<div class="flex gap-2">
<button
class="btn btn-ghost btn-sm gap-2"
onclick={() => (mostrarFiltros = !mostrarFiltros)}
>
<Filter class="h-4 w-4" />
Filtros
</button>
{#if busca || categoriaSelecionada || tipoSelecionado || tagsSelecionadas.length > 0}
<button class="btn btn-ghost btn-sm gap-2" onclick={limparFiltros}> Limpar </button>
{/if}
</div>
</div>
<!-- Filtros Expandidos -->
{#if mostrarFiltros}
<div class="border-base-300 mt-4 border-t pt-4">
<DocumentacaoSidebar
bind:categoriaSelecionada
bind:tipoSelecionado
bind:tagsSelecionadas
{categorias}
{tags}
/>
</div>
{/if}
</section>
<!-- Lista de Documentos -->
<section class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-base-content text-2xl font-bold">
Documentos
{#if documentosQuery}
<span class="text-base-content/50 text-lg font-normal">
({totalDocumentos})
</span>
{/if}
</h2>
</div>
{#if documentosQuery === undefined}
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if documentos.length === 0}
<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" />
<h3 class="text-base-content mb-2 text-xl font-semibold">Nenhum documento encontrado</h3>
<p class="text-base-content/70">
{#if busca || categoriaSelecionada || tipoSelecionado || tagsSelecionadas.length > 0}
Tente ajustar os filtros de busca.
{:else}
Ainda não há documentos cadastrados. Execute uma varredura para gerar documentação
automaticamente.
{/if}
</p>
</div>
{:else}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each documentos as documento (documento._id)}
<DocumentacaoCard
{documento}
selecionado={documentosSelecionados.includes(documento._id)}
onToggleSelecao={() => toggleSelecaoDocumento(documento._id)}
onVisualizar={() => abrirModal(documento._id)}
/>
{/each}
</div>
{/if}
</section>
</main>
<!-- Modal de Geração de PDF -->
{#if mostrarPdfGenerator}
<PdfGenerator
documentosIds={documentosSelecionados}
onClose={() => {
mostrarPdfGenerator = false;
documentosSelecionados = [];
}}
/>
{/if}
<!-- Modal de Visualização -->
<DocumentacaoModal documentoId={documentoModalId} onClose={fecharModal} />
</ProtectedRoute>

View File

@@ -1,531 +0,0 @@
<script lang="ts">
import { resolve, page } from '$app/paths';
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 ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { marked } from 'marked';
import { ArrowLeft, Download, Calendar, User, Tag, FileText, BookOpen } from 'lucide-svelte';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
const documentoId = $derived(page.params.id);
const documentoQuery = useQuery(
api.documentacao.obterDocumento,
documentoId ? { documentoId: documentoId as Id<'documentacao'> } : 'skip'
);
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() {
if (!documentoQuery) return;
try {
gerandoPdf = true;
const doc = new jsPDF();
const pageHeight = doc.internal.pageSize.getHeight();
const pageWidth = doc.internal.pageSize.getWidth();
const margin = 20;
const primaryColor = [102, 126, 234];
const secondaryColor = [128, 128, 128];
// Cabeçalho
doc.setFillColor(...primaryColor);
doc.rect(0, 0, pageWidth, 40, 'F');
doc.setTextColor(255, 255, 255);
doc.setFontSize(18);
doc.setFont('helvetica', 'bold');
doc.text(documentoQuery.titulo, pageWidth / 2, 25, { align: 'center', maxWidth: pageWidth - 2 * margin });
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.setTextColor(0, 0, 0);
doc.setFont('helvetica', 'normal');
let conteudoTexto = documentoQuery.conteudo;
const secoes = conteudoTexto.split(/\n##\s+/);
for (let j = 0; j < secoes.length; j++) {
let secao = secoes[j];
if (j === 0) {
secao = secao.replace(/^#\s+.*?\n\n/, '');
}
const tituloMatch = secao.match(/^(.+?)\n/);
if (tituloMatch && j > 0) {
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);
}
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
const pageCount = doc.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i);
doc.setFillColor(240, 240, 240);
doc.rect(0, pageHeight - 15, pageWidth, 15, 'F');
doc.setFontSize(8);
doc.setTextColor(...secondaryColor);
doc.setFont('helvetica', 'normal');
doc.text(
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
pageWidth / 2,
pageHeight - 7,
{ align: 'center' }
);
}
doc.save(`${documentoQuery.titulo.replace(/[^a-z0-9]/gi, '_')}.pdf`);
} catch (error) {
console.error('Erro ao gerar PDF:', error);
alert('Erro ao gerar PDF');
} finally {
gerandoPdf = false;
}
}
</script>
<ProtectedRoute allowedRoles={['ti_master', 'ti_usuario', 'admin']} maxLevel={1}>
<main class="mx-auto w-full max-w-5xl space-y-6 px-4 py-8">
<!-- Header -->
<div class="flex items-center justify-between">
<a
href={resolve('/ti/documentacao')}
class="btn btn-ghost btn-sm gap-2"
>
<ArrowLeft class="h-4 w-4" />
Voltar
</a>
<button class="btn btn-primary btn-sm gap-2" onclick={gerarPdfIndividual} disabled={gerandoPdf}>
<Download class="h-4 w-4" />
Gerar PDF
</button>
</div>
{#if documentoQuery === undefined}
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if !documentoQuery}
<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>
{:else}
<!-- Documento -->
<div class="flex gap-6">
<!-- Índice lateral -->
{#if indice().length > 0}
<aside class="hidden w-64 shrink-0 lg:block">
<div class="bg-base-100 border-base-300 sticky top-8 rounded-2xl border p-6 shadow-lg">
<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 -->
<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">
<Calendar class="h-4 w-4" />
<span>
Atualizado em {documentoQuery.atualizadoEm && !isNaN(new Date(documentoQuery.atualizadoEm).getTime())
? format(new Date(documentoQuery.atualizadoEm), "dd/MM/yyyy 'às' HH:mm", {
locale: ptBR
})
: 'Data não disponível'}
</span>
</div>
{#if documentoQuery.criadoPorUsuario}
<div class="flex items-center gap-2">
<User class="h-4 w-4" />
<span>Por {documentoQuery.criadoPorUsuario.nome}</span>
</div>
{/if}
<div class="flex items-center gap-2">
<FileText class="h-4 w-4" />
<span>Versão {documentoQuery.versao}</span>
</div>
</div>
</div>
</div>
</header>
<!-- Conteúdo principal -->
<div class="p-8">
<!-- Tags -->
{#if documentoQuery.tags && documentoQuery.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 documentoQuery.tags as tag}
<span class="badge badge-outline badge-sm">{tag}</span>
{/each}
</div>
{/if}
<!-- Conteúdo Markdown -->
<div class="prose prose-slate prose-lg max-w-none">
{@html conteudoHtml()}
</div>
<!-- Informações adicionais -->
{#if documentoQuery.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">{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}
<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}
</ul>
</div>
</div>
{/if}
{#if documentoQuery.metadados.retorno}
<div>
<h4 class="text-base-content mb-2 font-medium">Valor de Retorno:</h4>
<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>
{/if}
{#if documentoQuery.metadados.dependencias && documentoQuery.metadados.dependencias.length > 0}
<div>
<h4 class="text-base-content mb-2 font-medium">Dependências:</h4>
<div class="bg-base-100 rounded-lg p-4">
<ul class="space-y-2">
{#each documentoQuery.metadados.dependencias as dep}
<li class="text-base-content/80 flex items-start gap-2">
<span class="text-primary mt-1"></span>
<span>{dep}</span>
</li>
{/each}
</ul>
</div>
</div>
{/if}
</div>
</div>
{/if}
</div>
</article>
</div>
{/if}
</main>
</ProtectedRoute>
<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;
}
:global(.prose h2) {
color: hsl(var(--p));
font-size: 2em;
font-weight: 600;
margin-top: 1.5em;
margin-bottom: 0.75em;
}
: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));
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) {
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;
}
: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>

View File

@@ -1,521 +0,0 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import {
ArrowLeft,
Settings,
Play,
Clock,
Calendar,
CheckCircle2,
XCircle,
Loader2,
X,
CheckCircle
} from 'lucide-svelte';
const client = useConvexClient();
// Tipos
type DiaSemana = 'domingo' | 'segunda' | 'terca' | 'quarta' | 'quinta' | 'sexta' | 'sabado';
type ConfigVarredura = {
_id?: Id<'documentacaoConfig'>;
ativo: boolean;
diasSemana: DiaSemana[];
horario: string;
fusoHorario?: string;
ultimaExecucao?: number;
proximaExecucao?: number;
configuradoPor?: Id<'usuarios'>;
configuradoEm?: number;
atualizadoPor?: Id<'usuarios'>;
atualizadoEm?: number;
};
type Varredura = {
_id: Id<'documentacaoVarredura'>;
tipo: 'automatica' | 'manual';
status: 'em_andamento' | 'concluida' | 'erro' | 'cancelada';
documentosEncontrados: number;
documentosNovos: number;
documentosAtualizados: number;
arquivosAnalisados: number;
erros?: string[];
duracaoMs?: number;
executadoPor: Id<'usuarios'>;
iniciadoEm: number;
concluidoEm?: number;
executadoPorUsuario: {
_id: Id<'usuarios'>;
nome: string;
email: string;
} | null;
arquivosModificados?: Array<{
arquivo: string;
versao: string;
funcoes: string[];
}>;
totalArquivosModificados?: number;
};
// Estados - inicializar com valores padrão
let config = $state<ConfigVarredura>({
ativo: false,
diasSemana: [],
horario: '08:00',
fusoHorario: 'America/Recife'
});
let executandoVarredura = $state(false);
let historicoVarreduras = $state<Varredura[]>([]);
// Estados para modais
let showSuccessModal = $state(false);
let showErrorModal = $state(false);
let modalMessage = $state('');
let modalTitle = $state('');
// Queries
const configQuery = useQuery(api.documentacao.obterConfigVarredura, {});
// Dados derivados
$effect(() => {
if (configQuery) {
config = {
_id: configQuery._id,
ativo: configQuery.ativo ?? false,
diasSemana: configQuery.diasSemana ?? [],
horario: configQuery.horario ?? '08:00',
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
async function salvarConfig() {
try {
await client.mutation(api.documentacao.salvarConfigVarredura, {
ativo: config.ativo,
diasSemana: config.diasSemana,
horario: config.horario,
fusoHorario: config.fusoHorario
});
modalTitle = 'Sucesso';
modalMessage = 'Configuração salva com sucesso!';
showSuccessModal = true;
} catch (error) {
console.error('Erro ao salvar configuração:', error);
modalTitle = 'Erro';
modalMessage = 'Erro ao salvar configuração. Tente novamente.';
showErrorModal = true;
}
}
async function executarVarreduraManual() {
try {
executandoVarredura = true;
await client.mutation(api.documentacaoVarredura.executarVarreduraManual, {});
modalTitle = 'Varredura Iniciada';
modalMessage = 'Varredura iniciada! Você será notificado quando concluir.';
showSuccessModal = true;
} catch (error) {
console.error('Erro ao executar varredura:', error);
modalTitle = 'Erro';
modalMessage = 'Erro ao executar varredura. Tente novamente.';
showErrorModal = true;
} finally {
executandoVarredura = false;
}
}
function fecharModal() {
showSuccessModal = false;
showErrorModal = false;
modalMessage = '';
modalTitle = '';
}
const historicoQuery = useQuery(api.documentacaoVarredura.obterHistoricoVarreduras, () => ({
limite: 20
}));
// Extrair dados do histórico
$effect(() => {
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 = [
{ value: 'domingo', label: 'Domingo' },
{ value: 'segunda', label: 'Segunda-feira' },
{ value: 'terca', label: 'Terça-feira' },
{ value: 'quarta', label: 'Quarta-feira' },
{ value: 'quinta', label: 'Quinta-feira' },
{ value: 'sexta', label: 'Sexta-feira' },
{ value: 'sabado', label: 'Sábado' }
];
function toggleDiaSemana(dia: DiaSemana) {
if (config.diasSemana.includes(dia)) {
config.diasSemana = config.diasSemana.filter((d) => d !== dia);
} else {
config.diasSemana = [...config.diasSemana, dia];
}
}
const statusLabels: Record<string, string> = {
em_andamento: 'Em andamento',
concluida: 'Concluída',
erro: 'Erro',
cancelada: 'Cancelada'
};
const statusColors: Record<string, string> = {
em_andamento: 'badge-warning',
concluida: 'badge-success',
erro: 'badge-error',
cancelada: 'badge-ghost'
};
</script>
<ProtectedRoute allowedRoles={['ti_master', 'ti_usuario', 'admin']} maxLevel={1}>
<main class="mx-auto w-full max-w-5xl space-y-6 px-4 py-8">
<!-- Header -->
<div class="flex items-center justify-between">
<a href={resolve('/ti/documentacao')} class="btn btn-ghost btn-sm gap-2">
<ArrowLeft class="h-4 w-4" />
Voltar
</a>
</div>
<section
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-secondary/20 absolute right-0 -bottom-16 h-56 w-56 rounded-full blur-3xl"></div>
<div class="relative z-10">
<h1 class="text-base-content mb-2 text-4xl font-black">Configuração de Varredura</h1>
<p class="text-base-content/70 text-lg">
Configure o agendamento automático de varredura de documentação
</p>
</div>
</section>
{#if configQuery === undefined}
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<!-- Configuração -->
<section class="bg-base-100 rounded-2xl border border-base-300 p-6 shadow-lg">
<h2 class="text-base-content mb-6 flex items-center gap-2 text-2xl font-bold">
<Settings class="h-6 w-6" />
Agendamento
</h2>
<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 -->
<div class="form-control">
<label class="label cursor-pointer">
<span class="label-text text-base font-semibold">Ativar varredura automática</span>
<input
type="checkbox"
class="toggle toggle-primary"
bind:checked={config.ativo}
/>
</label>
</div>
<!-- Dias da Semana -->
<div>
<label class="label">
<span class="label-text text-base font-semibold">Dias da semana</span>
</label>
<div class="grid grid-cols-2 gap-3 md:grid-cols-4">
{#each diasSemana as dia}
<label class="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
class="checkbox checkbox-primary"
checked={config.diasSemana.includes(dia.value)}
onclick={() => toggleDiaSemana(dia.value)}
/>
<span>{dia.label}</span>
</label>
{/each}
</div>
</div>
<!-- Horário -->
<div>
<label class="label">
<span class="label-text text-base font-semibold">Horário</span>
</label>
<input
type="time"
class="input input-bordered w-full max-w-xs"
bind:value={config.horario}
/>
</div>
<!-- Botões -->
<div class="flex gap-3">
<button class="btn btn-primary" onclick={salvarConfig}>
Salvar Configuração
</button>
<button
class="btn btn-success gap-2"
onclick={executarVarreduraManual}
disabled={executandoVarredura}
>
{#if executandoVarredura}
<Loader2 class="h-5 w-5 animate-spin" />
Executando...
{:else}
<Play class="h-5 w-5" />
Executar Varredura Agora
{/if}
</button>
</div>
</div>
</section>
<!-- Histórico -->
<section class="bg-base-100 rounded-2xl border border-base-300 p-6 shadow-lg">
<h2 class="text-base-content mb-6 flex items-center gap-2 text-2xl font-bold">
<Calendar class="h-6 w-6" />
Histórico de Varreduras
</h2>
{#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">
<p class="text-base-content/70">Nenhuma varredura executada ainda</p>
</div>
{:else}
<div class="space-y-4">
{#each historicoVarreduras as varredura}
<div class="bg-base-200 rounded-lg border border-base-300 p-4">
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="badge badge-outline">
{varredura.tipo === 'automatica' ? 'Automática' : 'Manual'}
</span>
<span class="badge {statusColors[varredura.status] || 'badge-ghost'}">
{statusLabels[varredura.status] || varredura.status}
</span>
<span class="text-base-content/70 text-sm">
{varredura.iniciadoEm && !isNaN(new Date(varredura.iniciadoEm).getTime())
? format(new Date(varredura.iniciadoEm), "dd/MM/yyyy 'às' HH:mm", {
locale: ptBR
})
: 'Data não disponível'}
</span>
</div>
<div class="text-base-content/70 text-sm">
{varredura.duracaoMs
? `${(varredura.duracaoMs / 1000).toFixed(1)}s`
: '-'}
</div>
</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}
</div>
{/if}
</section>
{/if}
</main>
</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}