feat: add marked library for markdown parsing and enhance documentation handling with new cron job for scheduled checks
This commit is contained in:
@@ -57,6 +57,7 @@
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"lib-jitsi-meet": "^1.0.6",
|
||||
"lucide-svelte": "^0.552.0",
|
||||
"marked": "^17.0.1",
|
||||
"papaparse": "^5.4.1",
|
||||
"svelte-sonner": "^1.0.5",
|
||||
"xlsx": "^0.18.5",
|
||||
|
||||
145
apps/web/src/lib/components/documentacao/DocumentacaoCard.svelte
Normal file
145
apps/web/src/lib/components/documentacao/DocumentacaoCard.svelte
Normal 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
182
apps/web/src/lib/components/documentacao/PdfGenerator.svelte
Normal file
182
apps/web/src/lib/components/documentacao/PdfGenerator.svelte
Normal 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>
|
||||
|
||||
@@ -432,10 +432,11 @@
|
||||
title: 'Documentação',
|
||||
description:
|
||||
'Manuais, guias e documentação técnica do sistema para usuários e administradores.',
|
||||
ctaLabel: 'Em breve',
|
||||
ctaLabel: 'Acessar Biblioteca',
|
||||
href: '/(dashboard)/ti/documentacao',
|
||||
palette: 'primary',
|
||||
icon: 'document',
|
||||
disabled: true
|
||||
disabled: false
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
229
apps/web/src/routes/(dashboard)/ti/documentacao/+page.svelte
Normal file
229
apps/web/src/routes/(dashboard)/ti/documentacao/+page.svelte
Normal file
@@ -0,0 +1,229 @@
|
||||
<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 {
|
||||
FileText,
|
||||
Search,
|
||||
Filter,
|
||||
Settings,
|
||||
Download,
|
||||
BookOpen,
|
||||
Tag,
|
||||
Folder,
|
||||
ChevronRight
|
||||
} 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';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
// 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 documentosSelecionados = $state<Id<'documentacao'>[]>([]);
|
||||
|
||||
// Queries
|
||||
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) return [];
|
||||
return documentosQuery.documentos || [];
|
||||
});
|
||||
|
||||
const categorias = $derived.by(() => {
|
||||
if (!categoriasQuery) return [];
|
||||
return categoriasQuery || [];
|
||||
});
|
||||
|
||||
const tags = $derived.by(() => {
|
||||
if (!tagsQuery) return [];
|
||||
return tagsQuery || [];
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
</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 rounded-2xl border border-base-300 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">
|
||||
({documentosQuery.total})
|
||||
</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 rounded-2xl border border-base-300 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)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Modal de Geração de PDF -->
|
||||
{#if mostrarPdfGenerator}
|
||||
<PdfGenerator
|
||||
documentosIds={documentosSelecionados}
|
||||
onClose={() => {
|
||||
mostrarPdfGenerator = false;
|
||||
documentosSelecionados = [];
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</ProtectedRoute>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
<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 } from 'lucide-svelte';
|
||||
import jsPDF from 'jspdf';
|
||||
|
||||
const documentoId = $derived(page.params.id);
|
||||
const documentoQuery = useQuery(
|
||||
api.documentacao.obterDocumento,
|
||||
documentoId ? { documentoId: documentoId as Id<'documentacao'> } : 'skip'
|
||||
);
|
||||
|
||||
let gerandoPdf = $state(false);
|
||||
|
||||
async function gerarPdfIndividual() {
|
||||
if (!documentoQuery) return;
|
||||
|
||||
try {
|
||||
gerandoPdf = true;
|
||||
const doc = new jsPDF();
|
||||
let yPos = 20;
|
||||
const margin = 20;
|
||||
|
||||
// Título
|
||||
doc.setFontSize(20);
|
||||
doc.setTextColor(102, 126, 234);
|
||||
doc.text(documentoQuery.titulo, margin, yPos, { maxWidth: 170 });
|
||||
yPos += 15;
|
||||
|
||||
// Metadados
|
||||
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);
|
||||
|
||||
let conteudoTexto = documentoQuery.conteudo;
|
||||
conteudoTexto = conteudoTexto.replace(/#{1,6}\s+/g, '');
|
||||
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 (const linha of linhas) {
|
||||
if (yPos > doc.internal.pageSize.getHeight() - 20) {
|
||||
doc.addPage();
|
||||
yPos = margin;
|
||||
}
|
||||
doc.text(linha, margin, yPos);
|
||||
yPos += 6;
|
||||
}
|
||||
|
||||
// Footer
|
||||
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' }
|
||||
);
|
||||
}
|
||||
|
||||
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 -->
|
||||
<article class="bg-base-100 rounded-2xl border border-base-300 p-8 shadow-lg">
|
||||
<!-- Título -->
|
||||
<header class="mb-6">
|
||||
<h1 class="text-base-content mb-4 text-4xl font-bold">{documentoQuery.titulo}</h1>
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm text-base-content/70">
|
||||
<div class="flex items-center gap-2">
|
||||
<FileText class="h-4 w-4" />
|
||||
<span class="badge badge-primary">{documentoQuery.tipo}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Calendar class="h-4 w-4" />
|
||||
<span>
|
||||
{format(new Date(documentoQuery.atualizadoEm), "dd/MM/yyyy 'às' HH:mm", {
|
||||
locale: ptBR
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{#if documentoQuery.criadoPorUsuario}
|
||||
<div class="flex items-center gap-2">
|
||||
<User class="h-4 w-4" />
|
||||
<span>{documentoQuery.criadoPorUsuario.nome}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 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">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="prose prose-slate max-w-none">
|
||||
{@html marked.parse(documentoQuery.conteudo)}
|
||||
</div>
|
||||
|
||||
<!-- Metadados -->
|
||||
{#if documentoQuery.metadados}
|
||||
<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>
|
||||
{#if documentoQuery.metadados.parametros}
|
||||
<div class="mb-4">
|
||||
<h4 class="text-base-content mb-2 font-medium">Parâmetros:</h4>
|
||||
<ul class="list-inside list-disc">
|
||||
{#each documentoQuery.metadados.parametros as param}
|
||||
<li class="text-base-content/70">{param}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#if documentoQuery.metadados.retorno}
|
||||
<div class="mb-4">
|
||||
<h4 class="text-base-content mb-2 font-medium">Retorno:</h4>
|
||||
<p class="text-base-content/70">{documentoQuery.metadados.retorno}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if documentoQuery.metadados.dependencias}
|
||||
<div class="mb-4">
|
||||
<h4 class="text-base-content mb-2 font-medium">Dependências:</h4>
|
||||
<ul class="list-inside list-disc">
|
||||
{#each documentoQuery.metadados.dependencias as dep}
|
||||
<li class="text-base-content/70">{dep}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{/if}
|
||||
</main>
|
||||
</ProtectedRoute>
|
||||
|
||||
<style>
|
||||
:global(.prose) {
|
||||
color: hsl(var(--bc));
|
||||
}
|
||||
|
||||
:global(.prose h1),
|
||||
:global(.prose h2),
|
||||
:global(.prose h3) {
|
||||
color: hsl(var(--bc));
|
||||
}
|
||||
|
||||
:global(.prose code) {
|
||||
background-color: hsl(var(--b2));
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
:global(.prose pre) {
|
||||
background-color: hsl(var(--b2));
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
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
|
||||
} 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;
|
||||
};
|
||||
|
||||
// Estados
|
||||
let config = $state<ConfigVarredura | null>(null);
|
||||
let executandoVarredura = $state(false);
|
||||
let historicoVarreduras = $state<Varredura[]>([]);
|
||||
|
||||
// Queries
|
||||
const configQuery = useQuery(api.documentacao.obterConfigVarredura, {});
|
||||
|
||||
// Dados derivados
|
||||
$effect(() => {
|
||||
if (configQuery) {
|
||||
config = {
|
||||
ativo: configQuery.ativo ?? false,
|
||||
diasSemana: configQuery.diasSemana ?? [],
|
||||
horario: configQuery.horario ?? '08:00',
|
||||
fusoHorario: configQuery.fusoHorario ?? 'America/Recife'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Funções
|
||||
async function salvarConfig() {
|
||||
if (!config) return;
|
||||
|
||||
try {
|
||||
await client.mutation(api.documentacao.salvarConfigVarredura, {
|
||||
ativo: config.ativo,
|
||||
diasSemana: config.diasSemana,
|
||||
horario: config.horario,
|
||||
fusoHorario: config.fusoHorario
|
||||
});
|
||||
alert('Configuração salva com sucesso!');
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar configuração:', error);
|
||||
alert('Erro ao salvar configuração');
|
||||
}
|
||||
}
|
||||
|
||||
async function executarVarreduraManual() {
|
||||
try {
|
||||
executandoVarredura = true;
|
||||
await client.mutation(api.documentacaoVarredura.executarVarreduraManual, {});
|
||||
alert('Varredura iniciada! Você será notificado quando concluir.');
|
||||
// Recarregar histórico após alguns segundos
|
||||
setTimeout(() => {
|
||||
carregarHistorico();
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
console.error('Erro ao executar varredura:', error);
|
||||
alert('Erro ao executar varredura');
|
||||
} finally {
|
||||
executandoVarredura = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function carregarHistorico() {
|
||||
try {
|
||||
const historico = await client.mutation(api.documentacaoVarredura.obterHistoricoVarreduras, {
|
||||
limite: 20
|
||||
});
|
||||
historicoVarreduras = historico || [];
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar histórico:', error);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
carregarHistorico();
|
||||
});
|
||||
|
||||
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) return;
|
||||
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 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}
|
||||
<!-- 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">
|
||||
<!-- 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)}
|
||||
onchange={() => 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 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="overflow-x-auto">
|
||||
<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}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="badge badge-outline">
|
||||
{varredura.tipo === 'automatica' ? 'Automática' : 'Manual'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge {statusColors[varredura.status] || 'badge-ghost'}">
|
||||
{statusLabels[varredura.status] || varredura.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
Novos: {varredura.documentosNovos} | Atualizados:{' '}
|
||||
{varredura.documentosAtualizados}
|
||||
</td>
|
||||
<td>
|
||||
{varredura.executadoPorUsuario?.nome || 'N/A'}
|
||||
</td>
|
||||
<td>
|
||||
{format(new Date(varredura.iniciadoEm), "dd/MM/yyyy 'às' HH:mm", {
|
||||
locale: ptBR
|
||||
})}
|
||||
</td>
|
||||
<td>
|
||||
{varredura.duracaoMs
|
||||
? `${(varredura.duracaoMs / 1000).toFixed(1)}s`
|
||||
: '-'}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
</ProtectedRoute>
|
||||
|
||||
Reference in New Issue
Block a user