feat: add marked library for markdown parsing and enhance documentation handling with new cron job for scheduled checks

This commit is contained in:
2025-12-06 20:43:41 -03:00
parent f3b4721119
commit 0ec12721ba
17 changed files with 3033 additions and 4 deletions

View File

@@ -0,0 +1,145 @@
<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 } 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;
}
let { documento, selecionado = false, onToggleSelecao }: 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 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"
onclick={() => {
if (onToggleSelecao) {
onToggleSelecao();
} else {
window.location.href = resolve(`/ti/documentacao/${documento._id}`);
}
}}
>
<!-- Checkbox de seleção -->
{#if onToggleSelecao}
<button
class="absolute right-4 top-4 z-10"
onclick|stopPropagation={(e) => {
e.stopPropagation();
onToggleSelecao();
}}
>
{#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>
{format(new Date(documento.atualizadoEm), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}
</span>
</div>
{#if documento.visualizacoes !== undefined}
<span class="text-base-content/50 text-xs">{documento.visualizacoes} visualizações</span>
{/if}
</div>
</article>

View File

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

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

@@ -0,0 +1,182 @@
<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 { 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();
let yPos = 20;
const pageHeight = doc.internal.pageSize.getHeight();
const margin = 20;
// Título
doc.setFontSize(20);
doc.setTextColor(102, 126, 234);
doc.text('Biblioteca de Documentação SGSE', margin, yPos);
yPos += 15;
doc.setFontSize(12);
doc.setTextColor(0, 0, 0);
doc.text(`Gerado em: ${new Date().toLocaleString('pt-BR')}`, margin, yPos);
yPos += 10;
doc.text(`Total de documentos: ${documentos.length}`, margin, yPos);
yPos += 15;
// Índice
doc.setFontSize(16);
doc.setTextColor(102, 126, 234);
doc.text('Índice', margin, yPos);
yPos += 10;
doc.setFontSize(10);
doc.setTextColor(0, 0, 0);
documentos.forEach((documento, index) => {
if (yPos > pageHeight - 30) {
doc.addPage();
yPos = margin;
}
doc.text(`${index + 1}. ${documento.titulo}`, margin + 5, yPos);
yPos += 7;
});
yPos += 10;
// Documentos
for (let i = 0; i < documentos.length; i++) {
const documento = documentos[i];
// Nova página para cada documento
if (i > 0 || yPos > pageHeight - 50) {
doc.addPage();
yPos = margin;
}
// Título do documento
doc.setFontSize(18);
doc.setTextColor(102, 126, 234);
doc.text(documento.titulo, margin, yPos, { maxWidth: 170 });
yPos += 10;
// Metadados
doc.setFontSize(10);
doc.setTextColor(128, 128, 128);
doc.text(`Tipo: ${documento.tipo} | Versão: ${documento.versao}`, margin, yPos);
yPos += 8;
// Conteúdo
doc.setFontSize(11);
doc.setTextColor(0, 0, 0);
// Converter Markdown para texto simples (remover formatação)
let conteudoTexto = documento.conteudo;
// Remover markdown básico
conteudoTexto = conteudoTexto.replace(/#{1,6}\s+/g, ''); // Headers
conteudoTexto = conteudoTexto.replace(/\*\*(.*?)\*\*/g, '$1'); // Bold
conteudoTexto = conteudoTexto.replace(/\*(.*?)\*/g, '$1'); // Italic
conteudoTexto = conteudoTexto.replace(/`(.*?)`/g, '$1'); // Code
conteudoTexto = conteudoTexto.replace(/\[(.*?)\]\(.*?\)/g, '$1'); // Links
// Dividir em linhas e adicionar ao PDF
const linhas = doc.splitTextToSize(conteudoTexto, 170);
for (const linha of linhas) {
if (yPos > pageHeight - 20) {
doc.addPage();
yPos = margin;
}
doc.text(linha, margin, yPos);
yPos += 6;
}
yPos += 10;
}
// Footer em todas as páginas
const pageCount = doc.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i);
doc.setFontSize(8);
doc.setTextColor(128, 128, 128);
doc.text(
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
doc.internal.pageSize.getWidth() / 2,
doc.internal.pageSize.getHeight() - 10,
{ 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>