refactor: remove documentation components and related routes to streamline the application structure and improve maintainability
This commit is contained in:
@@ -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>
|
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
|
|
||||||
@@ -427,16 +427,6 @@
|
|||||||
href: '/(dashboard)/ti/configuracoes',
|
href: '/(dashboard)/ti/configuracoes',
|
||||||
palette: 'secondary',
|
palette: 'secondary',
|
||||||
icon: 'control'
|
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>
|
</script>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
|
|
||||||
@@ -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}
|
|
||||||
|
|
||||||
8
packages/backend/convex/_generated/api.d.ts
vendored
8
packages/backend/convex/_generated/api.d.ts
vendored
@@ -9,7 +9,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type * as acoes from "../acoes.js";
|
import type * as acoes from "../acoes.js";
|
||||||
import type * as actions_documentacaoVarredura from "../actions/documentacaoVarredura.js";
|
|
||||||
import type * as actions_email from "../actions/email.js";
|
import type * as actions_email from "../actions/email.js";
|
||||||
import type * as actions_linkPreview from "../actions/linkPreview.js";
|
import type * as actions_linkPreview from "../actions/linkPreview.js";
|
||||||
import type * as actions_pushNotifications from "../actions/pushNotifications.js";
|
import type * as actions_pushNotifications from "../actions/pushNotifications.js";
|
||||||
@@ -32,8 +31,6 @@ import type * as contratos from "../contratos.js";
|
|||||||
import type * as crons from "../crons.js";
|
import type * as crons from "../crons.js";
|
||||||
import type * as cursos from "../cursos.js";
|
import type * as cursos from "../cursos.js";
|
||||||
import type * as dashboard from "../dashboard.js";
|
import type * as dashboard from "../dashboard.js";
|
||||||
import type * as documentacao from "../documentacao.js";
|
|
||||||
import type * as documentacaoVarredura from "../documentacaoVarredura.js";
|
|
||||||
import type * as documentos from "../documentos.js";
|
import type * as documentos from "../documentos.js";
|
||||||
import type * as email from "../email.js";
|
import type * as email from "../email.js";
|
||||||
import type * as empresas from "../empresas.js";
|
import type * as empresas from "../empresas.js";
|
||||||
@@ -67,7 +64,6 @@ import type * as tables_auth from "../tables/auth.js";
|
|||||||
import type * as tables_chat from "../tables/chat.js";
|
import type * as tables_chat from "../tables/chat.js";
|
||||||
import type * as tables_contratos from "../tables/contratos.js";
|
import type * as tables_contratos from "../tables/contratos.js";
|
||||||
import type * as tables_cursos from "../tables/cursos.js";
|
import type * as tables_cursos from "../tables/cursos.js";
|
||||||
import type * as tables_documentacao from "../tables/documentacao.js";
|
|
||||||
import type * as tables_empresas from "../tables/empresas.js";
|
import type * as tables_empresas from "../tables/empresas.js";
|
||||||
import type * as tables_enderecos from "../tables/enderecos.js";
|
import type * as tables_enderecos from "../tables/enderecos.js";
|
||||||
import type * as tables_ferias from "../tables/ferias.js";
|
import type * as tables_ferias from "../tables/ferias.js";
|
||||||
@@ -101,7 +97,6 @@ import type {
|
|||||||
|
|
||||||
declare const fullApi: ApiFromModules<{
|
declare const fullApi: ApiFromModules<{
|
||||||
acoes: typeof acoes;
|
acoes: typeof acoes;
|
||||||
"actions/documentacaoVarredura": typeof actions_documentacaoVarredura;
|
|
||||||
"actions/email": typeof actions_email;
|
"actions/email": typeof actions_email;
|
||||||
"actions/linkPreview": typeof actions_linkPreview;
|
"actions/linkPreview": typeof actions_linkPreview;
|
||||||
"actions/pushNotifications": typeof actions_pushNotifications;
|
"actions/pushNotifications": typeof actions_pushNotifications;
|
||||||
@@ -124,8 +119,6 @@ declare const fullApi: ApiFromModules<{
|
|||||||
crons: typeof crons;
|
crons: typeof crons;
|
||||||
cursos: typeof cursos;
|
cursos: typeof cursos;
|
||||||
dashboard: typeof dashboard;
|
dashboard: typeof dashboard;
|
||||||
documentacao: typeof documentacao;
|
|
||||||
documentacaoVarredura: typeof documentacaoVarredura;
|
|
||||||
documentos: typeof documentos;
|
documentos: typeof documentos;
|
||||||
email: typeof email;
|
email: typeof email;
|
||||||
empresas: typeof empresas;
|
empresas: typeof empresas;
|
||||||
@@ -159,7 +152,6 @@ declare const fullApi: ApiFromModules<{
|
|||||||
"tables/chat": typeof tables_chat;
|
"tables/chat": typeof tables_chat;
|
||||||
"tables/contratos": typeof tables_contratos;
|
"tables/contratos": typeof tables_contratos;
|
||||||
"tables/cursos": typeof tables_cursos;
|
"tables/cursos": typeof tables_cursos;
|
||||||
"tables/documentacao": typeof tables_documentacao;
|
|
||||||
"tables/empresas": typeof tables_empresas;
|
"tables/empresas": typeof tables_empresas;
|
||||||
"tables/enderecos": typeof tables_enderecos;
|
"tables/enderecos": typeof tables_enderecos;
|
||||||
"tables/ferias": typeof tables_ferias;
|
"tables/ferias": typeof tables_ferias;
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
'use node';
|
|
||||||
|
|
||||||
import { action } from '../_generated/server';
|
|
||||||
import { v } from 'convex/values';
|
|
||||||
import { internal } from '../_generated/api';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Action para ler arquivos do sistema de arquivos e analisar funções
|
|
||||||
*/
|
|
||||||
export const analisarArquivos = action({
|
|
||||||
args: {
|
|
||||||
varreduraId: v.id('documentacaoVarredura'),
|
|
||||||
arquivos: v.array(v.string()),
|
|
||||||
executadoPor: v.id('usuarios')
|
|
||||||
},
|
|
||||||
returns: v.object({
|
|
||||||
arquivosAnalisados: v.number(),
|
|
||||||
documentosNovos: v.number(),
|
|
||||||
documentosAtualizados: v.number(),
|
|
||||||
erros: v.array(v.string())
|
|
||||||
}),
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
'use node';
|
|
||||||
|
|
||||||
const erros: string[] = [];
|
|
||||||
let arquivosAnalisados = 0;
|
|
||||||
let documentosNovos = 0;
|
|
||||||
let documentosAtualizados = 0;
|
|
||||||
|
|
||||||
console.log('🔍 [analisarArquivos] Iniciando análise de arquivos...');
|
|
||||||
console.log(`📁 Total de arquivos: ${args.arquivos.length}`);
|
|
||||||
console.log(`📋 Arquivos: ${args.arquivos.join(', ')}`);
|
|
||||||
|
|
||||||
// Tentar diferentes caminhos possíveis
|
|
||||||
const caminhosPossiveis = [
|
|
||||||
path.join(process.cwd(), 'packages/backend/convex'),
|
|
||||||
path.join(process.cwd(), 'convex'),
|
|
||||||
path.resolve(__dirname, '../../convex'),
|
|
||||||
path.resolve(__dirname, '../../../convex')
|
|
||||||
];
|
|
||||||
|
|
||||||
let basePath: string | null = null;
|
|
||||||
for (const caminho of caminhosPossiveis) {
|
|
||||||
if (fs.existsSync(caminho)) {
|
|
||||||
basePath = caminho;
|
|
||||||
console.log(`✅ Caminho encontrado: ${basePath}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!basePath) {
|
|
||||||
const erro = `Nenhum caminho válido encontrado. Tentados: ${caminhosPossiveis.join(', ')}`;
|
|
||||||
console.error(`❌ ${erro}`);
|
|
||||||
erros.push(erro);
|
|
||||||
await ctx.runMutation(internal.documentacaoVarredura.atualizarVarreduraComResultados, {
|
|
||||||
varreduraId: args.varreduraId,
|
|
||||||
documentosNovos: 0,
|
|
||||||
documentosAtualizados: 0,
|
|
||||||
arquivosAnalisados: 0,
|
|
||||||
erros: [erro]
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
arquivosAnalisados: 0,
|
|
||||||
documentosNovos: 0,
|
|
||||||
documentosAtualizados: 0,
|
|
||||||
erros: [erro]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const nomeArquivo of args.arquivos) {
|
|
||||||
try {
|
|
||||||
const arquivoPath = path.join(basePath, nomeArquivo);
|
|
||||||
console.log(`📄 Processando: ${arquivoPath}`);
|
|
||||||
|
|
||||||
// Verificar se o arquivo existe
|
|
||||||
if (!fs.existsSync(arquivoPath)) {
|
|
||||||
const erro = `Arquivo não encontrado: ${nomeArquivo} (caminho: ${arquivoPath})`;
|
|
||||||
console.error(`❌ ${erro}`);
|
|
||||||
erros.push(erro);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ler conteúdo do arquivo
|
|
||||||
const conteudo = fs.readFileSync(arquivoPath, 'utf-8');
|
|
||||||
console.log(`✅ Arquivo lido: ${nomeArquivo} (${conteudo.length} caracteres)`);
|
|
||||||
|
|
||||||
// Analisar funções no arquivo usando a mutation interna
|
|
||||||
const resultado = await ctx.runMutation(
|
|
||||||
internal.documentacaoVarredura.processarArquivo,
|
|
||||||
{
|
|
||||||
varreduraId: args.varreduraId,
|
|
||||||
nomeArquivo,
|
|
||||||
conteudo,
|
|
||||||
executadoPor: args.executadoPor
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`✅ Processado ${nomeArquivo}: ${resultado.documentosNovos} novos, ${resultado.documentosAtualizados} atualizados`
|
|
||||||
);
|
|
||||||
|
|
||||||
arquivosAnalisados++;
|
|
||||||
documentosNovos += resultado.documentosNovos;
|
|
||||||
documentosAtualizados += resultado.documentosAtualizados;
|
|
||||||
} catch (error) {
|
|
||||||
const erroMsg = error instanceof Error ? error.message : 'Erro desconhecido';
|
|
||||||
const erroStack = error instanceof Error ? error.stack : undefined;
|
|
||||||
const erroCompleto = `Erro ao processar ${nomeArquivo}: ${erroMsg}${erroStack ? `\n${erroStack}` : ''}`;
|
|
||||||
console.error(`❌ ${erroCompleto}`);
|
|
||||||
erros.push(erroCompleto);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`📊 Resumo: ${arquivosAnalisados} arquivos analisados, ${documentosNovos} novos, ${documentosAtualizados} atualizados`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Atualizar varredura com os resultados
|
|
||||||
await ctx.runMutation(internal.documentacaoVarredura.atualizarVarreduraComResultados, {
|
|
||||||
varreduraId: args.varreduraId,
|
|
||||||
documentosNovos,
|
|
||||||
documentosAtualizados,
|
|
||||||
arquivosAnalisados,
|
|
||||||
erros
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
arquivosAnalisados,
|
|
||||||
documentosNovos,
|
|
||||||
documentosAtualizados,
|
|
||||||
erros
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -58,12 +58,4 @@ crons.interval(
|
|||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verificar e executar varredura de documentação a cada hora (verifica configuração internamente)
|
|
||||||
crons.interval(
|
|
||||||
'verificar-varredura-documentacao',
|
|
||||||
{ hours: 1 },
|
|
||||||
internal.documentacaoVarredura.verificarEExecutarVarreduraAgendada,
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
|
|
||||||
export default crons;
|
export default crons;
|
||||||
|
|||||||
@@ -1,842 +0,0 @@
|
|||||||
import { v } from 'convex/values';
|
|
||||||
import { mutation, query } from './_generated/server';
|
|
||||||
import { Doc, Id } from './_generated/dataModel';
|
|
||||||
import type { QueryCtx, MutationCtx } from './_generated/server';
|
|
||||||
import { getCurrentUserFunction } from './auth';
|
|
||||||
import { api } from './_generated/api';
|
|
||||||
|
|
||||||
// ========== HELPERS ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normaliza texto para busca (remove acentos, converte para lowercase)
|
|
||||||
*/
|
|
||||||
function normalizarTextoParaBusca(texto: string): string {
|
|
||||||
return texto
|
|
||||||
.toLowerCase()
|
|
||||||
.normalize('NFD')
|
|
||||||
.replace(/[\u0300-\u036f]/g, '') // Remove diacríticos
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifica se o usuário tem permissão de TI (nível 0 ou 1)
|
|
||||||
*/
|
|
||||||
async function verificarPermissaoTI(ctx: QueryCtx | MutationCtx): Promise<Doc<'usuarios'>> {
|
|
||||||
const usuarioAtual = await getCurrentUserFunction(ctx);
|
|
||||||
if (!usuarioAtual) {
|
|
||||||
throw new Error('Não autenticado');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verificar se é TI (nível 0 ou 1)
|
|
||||||
const role = await ctx.db.get(usuarioAtual.roleId);
|
|
||||||
if (!role || (role.nivel > 1 && role.nome !== 'ti_master' && role.nome !== 'ti_usuario')) {
|
|
||||||
throw new Error('Acesso negado. Apenas usuários TI podem acessar a documentação.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return usuarioAtual;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== QUERIES ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Listar todos os documentos (com filtros opcionais)
|
|
||||||
*/
|
|
||||||
export const listarDocumentos = query({
|
|
||||||
args: {
|
|
||||||
categoriaId: v.optional(v.id('documentacaoCategorias')),
|
|
||||||
tipo: v.optional(
|
|
||||||
v.union(
|
|
||||||
v.literal('query'),
|
|
||||||
v.literal('mutation'),
|
|
||||||
v.literal('action'),
|
|
||||||
v.literal('component'),
|
|
||||||
v.literal('route'),
|
|
||||||
v.literal('modulo'),
|
|
||||||
v.literal('manual'),
|
|
||||||
v.literal('outro')
|
|
||||||
)
|
|
||||||
),
|
|
||||||
tags: v.optional(v.array(v.string())),
|
|
||||||
ativo: v.optional(v.boolean()),
|
|
||||||
busca: v.optional(v.string()),
|
|
||||||
limite: v.optional(v.number()),
|
|
||||||
offset: v.optional(v.number())
|
|
||||||
},
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
await verificarPermissaoTI(ctx);
|
|
||||||
|
|
||||||
let documentos: Doc<'documentacao'>[] = [];
|
|
||||||
|
|
||||||
// Aplicar filtros usando índices quando possível
|
|
||||||
if (args.categoriaId) {
|
|
||||||
documentos = await ctx.db
|
|
||||||
.query('documentacao')
|
|
||||||
.withIndex('by_categoria', (q) => q.eq('categoriaId', args.categoriaId))
|
|
||||||
.collect();
|
|
||||||
} else if (args.tipo) {
|
|
||||||
documentos = await ctx.db
|
|
||||||
.query('documentacao')
|
|
||||||
.withIndex('by_tipo', (q) => q.eq('tipo', args.tipo!))
|
|
||||||
.collect();
|
|
||||||
} else if (args.ativo !== undefined) {
|
|
||||||
documentos = await ctx.db
|
|
||||||
.query('documentacao')
|
|
||||||
.withIndex('by_ativo', (q) => q.eq('ativo', args.ativo!))
|
|
||||||
.collect();
|
|
||||||
} else {
|
|
||||||
documentos = await ctx.db.query('documentacao').collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtrar por tags (se fornecido)
|
|
||||||
if (args.tags && args.tags.length > 0) {
|
|
||||||
documentos = documentos.filter((doc) =>
|
|
||||||
args.tags!.some((tag) => doc.tags.includes(tag))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtrar por busca (full-text)
|
|
||||||
if (args.busca && args.busca.trim().length > 0) {
|
|
||||||
const buscaNormalizada = normalizarTextoParaBusca(args.busca);
|
|
||||||
documentos = documentos.filter((doc) => {
|
|
||||||
const tituloBusca = normalizarTextoParaBusca(doc.titulo);
|
|
||||||
return (
|
|
||||||
doc.conteudoBusca.includes(buscaNormalizada) ||
|
|
||||||
tituloBusca.includes(buscaNormalizada)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ordenar por data de atualização (mais recentes primeiro)
|
|
||||||
documentos.sort((a, b) => b.atualizadoEm - a.atualizadoEm);
|
|
||||||
|
|
||||||
// Aplicar paginação
|
|
||||||
const offset = args.offset || 0;
|
|
||||||
const limite = args.limite || 50;
|
|
||||||
const documentosPaginados = documentos.slice(offset, offset + limite);
|
|
||||||
|
|
||||||
// Enriquecer com informações de categoria
|
|
||||||
const documentosEnriquecidos = await Promise.all(
|
|
||||||
documentosPaginados.map(async (doc) => {
|
|
||||||
let categoria = null;
|
|
||||||
if (doc.categoriaId) {
|
|
||||||
categoria = await ctx.db.get(doc.categoriaId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...doc,
|
|
||||||
categoria
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
documentos: documentosEnriquecidos,
|
|
||||||
total: documentos.length,
|
|
||||||
offset,
|
|
||||||
limite
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obter um documento por ID
|
|
||||||
*/
|
|
||||||
export const obterDocumento = query({
|
|
||||||
args: {
|
|
||||||
documentoId: v.id('documentacao')
|
|
||||||
},
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
await verificarPermissaoTI(ctx);
|
|
||||||
|
|
||||||
const documento = await ctx.db.get(args.documentoId);
|
|
||||||
if (!documento) {
|
|
||||||
throw new Error('Documento não encontrado');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nota: Não podemos fazer patch em uma query, então o contador de visualizações
|
|
||||||
// será atualizado apenas quando o documento for atualizado via mutation
|
|
||||||
|
|
||||||
// Enriquecer com informações
|
|
||||||
let categoria = null;
|
|
||||||
if (documento.categoriaId) {
|
|
||||||
categoria = await ctx.db.get(documento.categoriaId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const criadoPor = await ctx.db.get(documento.criadoPor);
|
|
||||||
const atualizadoPor = documento.atualizadoPor
|
|
||||||
? await ctx.db.get(documento.atualizadoPor)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...documento,
|
|
||||||
categoria,
|
|
||||||
criadoPorUsuario: criadoPor
|
|
||||||
? {
|
|
||||||
_id: criadoPor._id,
|
|
||||||
nome: criadoPor.nome,
|
|
||||||
email: criadoPor.email
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
atualizadoPorUsuario: atualizadoPor
|
|
||||||
? {
|
|
||||||
_id: atualizadoPor._id,
|
|
||||||
nome: atualizadoPor.nome,
|
|
||||||
email: atualizadoPor.email
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Buscar documentos (full-text search)
|
|
||||||
*/
|
|
||||||
export const buscarDocumentos = query({
|
|
||||||
args: {
|
|
||||||
query: v.string(),
|
|
||||||
categoriaId: v.optional(v.id('documentacaoCategorias')),
|
|
||||||
tipo: v.optional(
|
|
||||||
v.union(
|
|
||||||
v.literal('query'),
|
|
||||||
v.literal('mutation'),
|
|
||||||
v.literal('action'),
|
|
||||||
v.literal('component'),
|
|
||||||
v.literal('route'),
|
|
||||||
v.literal('modulo'),
|
|
||||||
v.literal('manual'),
|
|
||||||
v.literal('outro')
|
|
||||||
)
|
|
||||||
),
|
|
||||||
tags: v.optional(v.array(v.string())),
|
|
||||||
limite: v.optional(v.number())
|
|
||||||
},
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
await verificarPermissaoTI(ctx);
|
|
||||||
|
|
||||||
const queryNormalizada = normalizarTextoParaBusca(args.query);
|
|
||||||
const limite = args.limite || 50;
|
|
||||||
|
|
||||||
// Buscar todos os documentos ativos
|
|
||||||
let documentos = await ctx.db
|
|
||||||
.query('documentacao')
|
|
||||||
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Filtrar por busca
|
|
||||||
documentos = documentos.filter((doc) => {
|
|
||||||
const tituloBusca = normalizarTextoParaBusca(doc.titulo);
|
|
||||||
return (
|
|
||||||
doc.conteudoBusca.includes(queryNormalizada) ||
|
|
||||||
tituloBusca.includes(queryNormalizada) ||
|
|
||||||
doc.tags.some((tag) => normalizarTextoParaBusca(tag).includes(queryNormalizada))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Aplicar filtros adicionais
|
|
||||||
if (args.categoriaId) {
|
|
||||||
documentos = documentos.filter((doc) => doc.categoriaId === args.categoriaId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.tipo) {
|
|
||||||
documentos = documentos.filter((doc) => doc.tipo === args.tipo);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.tags && args.tags.length > 0) {
|
|
||||||
documentos = documentos.filter((doc) =>
|
|
||||||
args.tags!.some((tag) => doc.tags.includes(tag))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ordenar por relevância (simples: documentos com mais matches primeiro)
|
|
||||||
documentos.sort((a, b) => {
|
|
||||||
const aMatches =
|
|
||||||
(normalizarTextoParaBusca(a.titulo).includes(queryNormalizada) ? 2 : 0) +
|
|
||||||
(a.conteudoBusca.includes(queryNormalizada) ? 1 : 0);
|
|
||||||
const bMatches =
|
|
||||||
(normalizarTextoParaBusca(b.titulo).includes(queryNormalizada) ? 2 : 0) +
|
|
||||||
(b.conteudoBusca.includes(queryNormalizada) ? 1 : 0);
|
|
||||||
return bMatches - aMatches;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Limitar resultados
|
|
||||||
documentos = documentos.slice(0, limite);
|
|
||||||
|
|
||||||
// Enriquecer com categoria
|
|
||||||
const documentosEnriquecidos = await Promise.all(
|
|
||||||
documentos.map(async (doc) => {
|
|
||||||
let categoria = null;
|
|
||||||
if (doc.categoriaId) {
|
|
||||||
categoria = await ctx.db.get(doc.categoriaId);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...doc,
|
|
||||||
categoria
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return documentosEnriquecidos;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Listar categorias
|
|
||||||
*/
|
|
||||||
export const listarCategorias = query({
|
|
||||||
args: {
|
|
||||||
parentId: v.optional(v.id('documentacaoCategorias')),
|
|
||||||
ativo: v.optional(v.boolean())
|
|
||||||
},
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
await verificarPermissaoTI(ctx);
|
|
||||||
|
|
||||||
let categorias: Doc<'documentacaoCategorias'>[] = [];
|
|
||||||
|
|
||||||
if (args.parentId !== undefined) {
|
|
||||||
categorias = await ctx.db
|
|
||||||
.query('documentacaoCategorias')
|
|
||||||
.withIndex('by_parent', (q) => q.eq('parentId', args.parentId))
|
|
||||||
.collect();
|
|
||||||
} else if (args.ativo !== undefined) {
|
|
||||||
categorias = await ctx.db
|
|
||||||
.query('documentacaoCategorias')
|
|
||||||
.withIndex('by_ativo', (q) => q.eq('ativo', args.ativo!))
|
|
||||||
.collect();
|
|
||||||
} else {
|
|
||||||
categorias = await ctx.db.query('documentacaoCategorias').collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ordenar por ordem
|
|
||||||
categorias.sort((a, b) => a.ordem - b.ordem);
|
|
||||||
|
|
||||||
return categorias;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obter múltiplos documentos por IDs
|
|
||||||
*/
|
|
||||||
export const obterDocumentosPorIds = query({
|
|
||||||
args: {
|
|
||||||
documentosIds: v.array(v.id('documentacao'))
|
|
||||||
},
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
await verificarPermissaoTI(ctx);
|
|
||||||
|
|
||||||
const documentos = await Promise.all(
|
|
||||||
args.documentosIds.map(async (id) => {
|
|
||||||
const doc = await ctx.db.get(id);
|
|
||||||
if (!doc) return null;
|
|
||||||
|
|
||||||
let categoria = null;
|
|
||||||
if (doc.categoriaId) {
|
|
||||||
categoria = await ctx.db.get(doc.categoriaId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...doc,
|
|
||||||
categoria
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return documentos.filter((doc): doc is NonNullable<typeof doc> => doc !== null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Listar tags
|
|
||||||
*/
|
|
||||||
export const listarTags = query({
|
|
||||||
args: {
|
|
||||||
ativo: v.optional(v.boolean()),
|
|
||||||
limite: v.optional(v.number())
|
|
||||||
},
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
await verificarPermissaoTI(ctx);
|
|
||||||
|
|
||||||
let tags: Doc<'documentacaoTags'>[] = [];
|
|
||||||
|
|
||||||
if (args.ativo !== undefined) {
|
|
||||||
tags = await ctx.db
|
|
||||||
.query('documentacaoTags')
|
|
||||||
.withIndex('by_ativo', (q) => q.eq('ativo', args.ativo!))
|
|
||||||
.collect();
|
|
||||||
} else {
|
|
||||||
tags = await ctx.db.query('documentacaoTags').collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ordenar por uso (mais usadas primeiro)
|
|
||||||
tags.sort((a, b) => b.usadoEm - a.usadoEm);
|
|
||||||
|
|
||||||
// Limitar se necessário
|
|
||||||
if (args.limite) {
|
|
||||||
tags = tags.slice(0, args.limite);
|
|
||||||
}
|
|
||||||
|
|
||||||
return tags;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ========== MUTATIONS ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Criar novo documento
|
|
||||||
*/
|
|
||||||
export const criarDocumento = mutation({
|
|
||||||
args: {
|
|
||||||
titulo: v.string(),
|
|
||||||
conteudo: v.string(),
|
|
||||||
conteudoHtml: v.optional(v.string()),
|
|
||||||
categoriaId: v.optional(v.id('documentacaoCategorias')),
|
|
||||||
tags: v.array(v.string()),
|
|
||||||
tipo: v.union(
|
|
||||||
v.literal('query'),
|
|
||||||
v.literal('mutation'),
|
|
||||||
v.literal('action'),
|
|
||||||
v.literal('component'),
|
|
||||||
v.literal('route'),
|
|
||||||
v.literal('modulo'),
|
|
||||||
v.literal('manual'),
|
|
||||||
v.literal('outro')
|
|
||||||
),
|
|
||||||
versao: v.string(),
|
|
||||||
arquivoOrigem: v.optional(v.string()),
|
|
||||||
funcaoOrigem: v.optional(v.string()),
|
|
||||||
hashOrigem: v.optional(v.string()),
|
|
||||||
metadados: v.optional(
|
|
||||||
v.object({
|
|
||||||
parametros: v.optional(v.array(v.string())),
|
|
||||||
retorno: v.optional(v.string()),
|
|
||||||
dependencias: v.optional(v.array(v.string())),
|
|
||||||
exemplos: v.optional(v.array(v.string())),
|
|
||||||
algoritmo: v.optional(v.string())
|
|
||||||
})
|
|
||||||
),
|
|
||||||
geradoAutomaticamente: v.optional(v.boolean())
|
|
||||||
},
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
const usuarioAtual = await verificarPermissaoTI(ctx);
|
|
||||||
|
|
||||||
const agora = Date.now();
|
|
||||||
const conteudoBusca = normalizarTextoParaBusca(args.titulo + ' ' + args.conteudo);
|
|
||||||
|
|
||||||
const documentoId = await ctx.db.insert('documentacao', {
|
|
||||||
titulo: args.titulo,
|
|
||||||
conteudo: args.conteudo,
|
|
||||||
conteudoHtml: args.conteudoHtml,
|
|
||||||
conteudoBusca,
|
|
||||||
categoriaId: args.categoriaId,
|
|
||||||
tags: args.tags,
|
|
||||||
tipo: args.tipo,
|
|
||||||
versao: args.versao,
|
|
||||||
arquivoOrigem: args.arquivoOrigem,
|
|
||||||
funcaoOrigem: args.funcaoOrigem,
|
|
||||||
hashOrigem: args.hashOrigem,
|
|
||||||
metadados: args.metadados,
|
|
||||||
ativo: true,
|
|
||||||
criadoPor: usuarioAtual._id,
|
|
||||||
criadoEm: agora,
|
|
||||||
atualizadoEm: agora,
|
|
||||||
visualizacoes: 0,
|
|
||||||
geradoAutomaticamente: args.geradoAutomaticamente ?? false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Atualizar contadores de uso das tags
|
|
||||||
for (const tagNome of args.tags) {
|
|
||||||
const tag = await ctx.db
|
|
||||||
.query('documentacaoTags')
|
|
||||||
.withIndex('by_nome', (q) => q.eq('nome', tagNome))
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (tag) {
|
|
||||||
await ctx.db.patch(tag._id, {
|
|
||||||
usadoEm: tag.usadoEm + 1
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return documentoId;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Atualizar documento
|
|
||||||
*/
|
|
||||||
export const atualizarDocumento = mutation({
|
|
||||||
args: {
|
|
||||||
documentoId: v.id('documentacao'),
|
|
||||||
titulo: v.optional(v.string()),
|
|
||||||
conteudo: v.optional(v.string()),
|
|
||||||
conteudoHtml: v.optional(v.string()),
|
|
||||||
categoriaId: v.optional(v.id('documentacaoCategorias')),
|
|
||||||
tags: v.optional(v.array(v.string())),
|
|
||||||
tipo: v.optional(
|
|
||||||
v.union(
|
|
||||||
v.literal('query'),
|
|
||||||
v.literal('mutation'),
|
|
||||||
v.literal('action'),
|
|
||||||
v.literal('component'),
|
|
||||||
v.literal('route'),
|
|
||||||
v.literal('modulo'),
|
|
||||||
v.literal('manual'),
|
|
||||||
v.literal('outro')
|
|
||||||
)
|
|
||||||
),
|
|
||||||
versao: v.optional(v.string()),
|
|
||||||
metadados: v.optional(
|
|
||||||
v.object({
|
|
||||||
parametros: v.optional(v.array(v.string())),
|
|
||||||
retorno: v.optional(v.string()),
|
|
||||||
dependencias: v.optional(v.array(v.string())),
|
|
||||||
exemplos: v.optional(v.array(v.string())),
|
|
||||||
algoritmo: v.optional(v.string())
|
|
||||||
})
|
|
||||||
),
|
|
||||||
ativo: v.optional(v.boolean())
|
|
||||||
},
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
const usuarioAtual = await verificarPermissaoTI(ctx);
|
|
||||||
|
|
||||||
const documento = await ctx.db.get(args.documentoId);
|
|
||||||
if (!documento) {
|
|
||||||
throw new Error('Documento não encontrado');
|
|
||||||
}
|
|
||||||
|
|
||||||
const atualizacoes: Partial<Doc<'documentacao'>> = {
|
|
||||||
atualizadoPor: usuarioAtual._id,
|
|
||||||
atualizadoEm: Date.now()
|
|
||||||
};
|
|
||||||
|
|
||||||
if (args.titulo !== undefined) atualizacoes.titulo = args.titulo;
|
|
||||||
if (args.conteudo !== undefined) atualizacoes.conteudo = args.conteudo;
|
|
||||||
if (args.conteudoHtml !== undefined) atualizacoes.conteudoHtml = args.conteudoHtml;
|
|
||||||
if (args.categoriaId !== undefined) atualizacoes.categoriaId = args.categoriaId;
|
|
||||||
if (args.tags !== undefined) atualizacoes.tags = args.tags;
|
|
||||||
if (args.tipo !== undefined) atualizacoes.tipo = args.tipo;
|
|
||||||
if (args.versao !== undefined) atualizacoes.versao = args.versao;
|
|
||||||
if (args.metadados !== undefined) atualizacoes.metadados = args.metadados;
|
|
||||||
if (args.ativo !== undefined) atualizacoes.ativo = args.ativo;
|
|
||||||
|
|
||||||
// Atualizar conteúdo de busca se título ou conteúdo mudaram
|
|
||||||
if (args.titulo !== undefined || args.conteudo !== undefined) {
|
|
||||||
const novoTitulo = args.titulo ?? documento.titulo;
|
|
||||||
const novoConteudo = args.conteudo ?? documento.conteudo;
|
|
||||||
atualizacoes.conteudoBusca = normalizarTextoParaBusca(novoTitulo + ' ' + novoConteudo);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Atualizar contadores de tags se tags mudaram
|
|
||||||
if (args.tags !== undefined) {
|
|
||||||
// Decrementar tags antigas
|
|
||||||
for (const tagNome of documento.tags) {
|
|
||||||
if (!args.tags.includes(tagNome)) {
|
|
||||||
const tag = await ctx.db
|
|
||||||
.query('documentacaoTags')
|
|
||||||
.withIndex('by_nome', (q) => q.eq('nome', tagNome))
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (tag && tag.usadoEm > 0) {
|
|
||||||
await ctx.db.patch(tag._id, {
|
|
||||||
usadoEm: tag.usadoEm - 1
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Incrementar tags novas
|
|
||||||
for (const tagNome of args.tags) {
|
|
||||||
if (!documento.tags.includes(tagNome)) {
|
|
||||||
const tag = await ctx.db
|
|
||||||
.query('documentacaoTags')
|
|
||||||
.withIndex('by_nome', (q) => q.eq('nome', tagNome))
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (tag) {
|
|
||||||
await ctx.db.patch(tag._id, {
|
|
||||||
usadoEm: tag.usadoEm + 1
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.db.patch(args.documentoId, atualizacoes);
|
|
||||||
|
|
||||||
return { sucesso: true };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deletar documento (soft delete)
|
|
||||||
*/
|
|
||||||
export const deletarDocumento = mutation({
|
|
||||||
args: {
|
|
||||||
documentoId: v.id('documentacao')
|
|
||||||
},
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
const usuarioAtual = await verificarPermissaoTI(ctx);
|
|
||||||
|
|
||||||
const documento = await ctx.db.get(args.documentoId);
|
|
||||||
if (!documento) {
|
|
||||||
throw new Error('Documento não encontrado');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Soft delete: apenas marcar como inativo
|
|
||||||
await ctx.db.patch(args.documentoId, {
|
|
||||||
ativo: false,
|
|
||||||
atualizadoPor: usuarioAtual._id,
|
|
||||||
atualizadoEm: Date.now()
|
|
||||||
});
|
|
||||||
|
|
||||||
// Decrementar contadores de tags
|
|
||||||
for (const tagNome of documento.tags) {
|
|
||||||
const tag = await ctx.db
|
|
||||||
.query('documentacaoTags')
|
|
||||||
.withIndex('by_nome', (q) => q.eq('nome', tagNome))
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (tag && tag.usadoEm > 0) {
|
|
||||||
await ctx.db.patch(tag._id, {
|
|
||||||
usadoEm: tag.usadoEm - 1
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { sucesso: true };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Criar categoria
|
|
||||||
*/
|
|
||||||
export const criarCategoria = mutation({
|
|
||||||
args: {
|
|
||||||
nome: v.string(),
|
|
||||||
descricao: v.optional(v.string()),
|
|
||||||
icone: v.optional(v.string()),
|
|
||||||
cor: v.optional(
|
|
||||||
v.union(
|
|
||||||
v.literal('primary'),
|
|
||||||
v.literal('secondary'),
|
|
||||||
v.literal('accent'),
|
|
||||||
v.literal('success'),
|
|
||||||
v.literal('warning'),
|
|
||||||
v.literal('error'),
|
|
||||||
v.literal('info')
|
|
||||||
)
|
|
||||||
),
|
|
||||||
parentId: v.optional(v.id('documentacaoCategorias')),
|
|
||||||
ordem: v.number()
|
|
||||||
},
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
await verificarPermissaoTI(ctx);
|
|
||||||
|
|
||||||
const usuarioAtual = await getCurrentUserFunction(ctx);
|
|
||||||
if (!usuarioAtual) {
|
|
||||||
throw new Error('Não autenticado');
|
|
||||||
}
|
|
||||||
|
|
||||||
const agora = Date.now();
|
|
||||||
const categoriaId = await ctx.db.insert('documentacaoCategorias', {
|
|
||||||
nome: args.nome,
|
|
||||||
descricao: args.descricao,
|
|
||||||
icone: args.icone,
|
|
||||||
cor: args.cor,
|
|
||||||
parentId: args.parentId,
|
|
||||||
ordem: args.ordem,
|
|
||||||
ativo: true,
|
|
||||||
criadoPor: usuarioAtual._id,
|
|
||||||
criadoEm: agora,
|
|
||||||
atualizadoEm: agora
|
|
||||||
});
|
|
||||||
|
|
||||||
return categoriaId;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Criar tag
|
|
||||||
*/
|
|
||||||
export const criarTag = mutation({
|
|
||||||
args: {
|
|
||||||
nome: v.string(),
|
|
||||||
descricao: v.optional(v.string()),
|
|
||||||
cor: v.optional(
|
|
||||||
v.union(
|
|
||||||
v.literal('primary'),
|
|
||||||
v.literal('secondary'),
|
|
||||||
v.literal('accent'),
|
|
||||||
v.literal('success'),
|
|
||||||
v.literal('warning'),
|
|
||||||
v.literal('error'),
|
|
||||||
v.literal('info')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
await verificarPermissaoTI(ctx);
|
|
||||||
|
|
||||||
// Verificar se tag já existe
|
|
||||||
const tagExistente = await ctx.db
|
|
||||||
.query('documentacaoTags')
|
|
||||||
.withIndex('by_nome', (q) => q.eq('nome', args.nome))
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (tagExistente) {
|
|
||||||
throw new Error('Tag já existe');
|
|
||||||
}
|
|
||||||
|
|
||||||
const usuarioAtual = await getCurrentUserFunction(ctx);
|
|
||||||
if (!usuarioAtual) {
|
|
||||||
throw new Error('Não autenticado');
|
|
||||||
}
|
|
||||||
|
|
||||||
const agora = Date.now();
|
|
||||||
const tagId = await ctx.db.insert('documentacaoTags', {
|
|
||||||
nome: args.nome,
|
|
||||||
descricao: args.descricao,
|
|
||||||
cor: args.cor,
|
|
||||||
usadoEm: 0,
|
|
||||||
ativo: true,
|
|
||||||
criadoPor: usuarioAtual._id,
|
|
||||||
criadoEm: agora
|
|
||||||
});
|
|
||||||
|
|
||||||
return tagId;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obter configuração de agendamento de varredura
|
|
||||||
*/
|
|
||||||
export const obterConfigVarredura = query({
|
|
||||||
args: {},
|
|
||||||
handler: async (ctx) => {
|
|
||||||
await verificarPermissaoTI(ctx);
|
|
||||||
|
|
||||||
const config = await ctx.db
|
|
||||||
.query('documentacaoConfig')
|
|
||||||
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
|
||||||
.first();
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Salvar configuração de agendamento de varredura
|
|
||||||
*/
|
|
||||||
export const salvarConfigVarredura = mutation({
|
|
||||||
args: {
|
|
||||||
ativo: v.boolean(),
|
|
||||||
diasSemana: v.array(
|
|
||||||
v.union(
|
|
||||||
v.literal('domingo'),
|
|
||||||
v.literal('segunda'),
|
|
||||||
v.literal('terca'),
|
|
||||||
v.literal('quarta'),
|
|
||||||
v.literal('quinta'),
|
|
||||||
v.literal('sexta'),
|
|
||||||
v.literal('sabado')
|
|
||||||
)
|
|
||||||
),
|
|
||||||
horario: v.string(), // Formato "HH:MM"
|
|
||||||
fusoHorario: v.optional(v.string())
|
|
||||||
},
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
const usuarioAtual = await verificarPermissaoTI(ctx);
|
|
||||||
|
|
||||||
// Buscar configuração existente
|
|
||||||
const configExistente = await ctx.db
|
|
||||||
.query('documentacaoConfig')
|
|
||||||
.withIndex('by_ativo', (q) => q.eq('ativo', true))
|
|
||||||
.first();
|
|
||||||
|
|
||||||
const agora = Date.now();
|
|
||||||
|
|
||||||
if (configExistente) {
|
|
||||||
// Atualizar configuração existente
|
|
||||||
await ctx.db.patch(configExistente._id, {
|
|
||||||
ativo: args.ativo,
|
|
||||||
diasSemana: args.diasSemana,
|
|
||||||
horario: args.horario,
|
|
||||||
fusoHorario: args.fusoHorario,
|
|
||||||
atualizadoPor: usuarioAtual._id,
|
|
||||||
atualizadoEm: agora
|
|
||||||
});
|
|
||||||
|
|
||||||
return configExistente._id;
|
|
||||||
} else {
|
|
||||||
// Criar nova configuração
|
|
||||||
const configId = await ctx.db.insert('documentacaoConfig', {
|
|
||||||
ativo: args.ativo,
|
|
||||||
diasSemana: args.diasSemana,
|
|
||||||
horario: args.horario,
|
|
||||||
fusoHorario: args.fusoHorario || 'America/Recife',
|
|
||||||
configuradoPor: usuarioAtual._id,
|
|
||||||
configuradoEm: agora,
|
|
||||||
atualizadoEm: agora
|
|
||||||
});
|
|
||||||
|
|
||||||
return configId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Notificar TI_master sobre novos documentos criados
|
|
||||||
*/
|
|
||||||
export const notificarNovosDocumentos = mutation({
|
|
||||||
args: {
|
|
||||||
documentosIds: v.array(v.id('documentacao')),
|
|
||||||
quantidade: v.number()
|
|
||||||
},
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
// Buscar usuários TI_master (nível 0)
|
|
||||||
const roleTIMaster = await ctx.db
|
|
||||||
.query('roles')
|
|
||||||
.filter((q) => q.eq(q.field('nivel'), 0))
|
|
||||||
.first();
|
|
||||||
|
|
||||||
if (!roleTIMaster) {
|
|
||||||
return; // Não há TI_master configurado
|
|
||||||
}
|
|
||||||
|
|
||||||
const usuariosTIMaster = await ctx.db
|
|
||||||
.query('usuarios')
|
|
||||||
.filter((q) => q.eq(q.field('roleId'), roleTIMaster._id))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Criar notificações no chat
|
|
||||||
for (const usuario of usuariosTIMaster) {
|
|
||||||
await ctx.db.insert('notificacoes', {
|
|
||||||
usuarioId: usuario._id,
|
|
||||||
tipo: 'nova_mensagem',
|
|
||||||
titulo: '📚 Novos Documentos Criados',
|
|
||||||
descricao: `${args.quantidade} novo(s) documento(s) de documentação foram criados automaticamente pela varredura do sistema.`,
|
|
||||||
lida: false,
|
|
||||||
criadaEm: Date.now()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enviar emails para TI_master (usando scheduler para não bloquear)
|
|
||||||
for (const usuario of usuariosTIMaster) {
|
|
||||||
if (usuario.email) {
|
|
||||||
await ctx.scheduler.runAfter(0, api.email.enviarEmailComTemplate, {
|
|
||||||
destinatario: usuario.email,
|
|
||||||
destinatarioId: usuario._id,
|
|
||||||
templateCodigo: 'documentacao_novos_documentos',
|
|
||||||
variaveis: {
|
|
||||||
destinatarioNome: usuario.nome,
|
|
||||||
quantidade: args.quantidade.toString(),
|
|
||||||
urlSistema: process.env.FRONTEND_URL || 'http://localhost:5173'
|
|
||||||
},
|
|
||||||
enviadoPor: usuario._id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { sucesso: true };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,6 @@ import { pontoTables } from './tables/ponto';
|
|||||||
import { pedidosTables } from './tables/pedidos';
|
import { pedidosTables } from './tables/pedidos';
|
||||||
import { produtosTables } from './tables/produtos';
|
import { produtosTables } from './tables/produtos';
|
||||||
import { lgpdTables } from './tables/lgpdTables';
|
import { lgpdTables } from './tables/lgpdTables';
|
||||||
import { documentacaoTables } from './tables/documentacao';
|
|
||||||
|
|
||||||
export default defineSchema({
|
export default defineSchema({
|
||||||
...setoresTables,
|
...setoresTables,
|
||||||
@@ -43,6 +42,5 @@ export default defineSchema({
|
|||||||
...pontoTables,
|
...pontoTables,
|
||||||
...pedidosTables,
|
...pedidosTables,
|
||||||
...produtosTables,
|
...produtosTables,
|
||||||
...lgpdTables,
|
...lgpdTables
|
||||||
...documentacaoTables
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
import { defineTable } from 'convex/server';
|
|
||||||
import { v } from 'convex/values';
|
|
||||||
|
|
||||||
export const documentacaoTables = {
|
|
||||||
// Documentos principais
|
|
||||||
documentacao: defineTable({
|
|
||||||
titulo: v.string(),
|
|
||||||
conteudo: v.string(), // Conteúdo em Markdown
|
|
||||||
conteudoHtml: v.optional(v.string()), // Conteúdo renderizado em HTML
|
|
||||||
conteudoBusca: v.string(), // Versão normalizada para busca (lowercase, sem acentos)
|
|
||||||
categoriaId: v.optional(v.id('documentacaoCategorias')),
|
|
||||||
tags: v.array(v.string()),
|
|
||||||
tipo: v.union(
|
|
||||||
v.literal('query'),
|
|
||||||
v.literal('mutation'),
|
|
||||||
v.literal('action'),
|
|
||||||
v.literal('component'),
|
|
||||||
v.literal('route'),
|
|
||||||
v.literal('modulo'),
|
|
||||||
v.literal('manual'),
|
|
||||||
v.literal('outro')
|
|
||||||
),
|
|
||||||
versao: v.string(), // Versão do documento (ex: "1.0.0")
|
|
||||||
arquivoOrigem: v.optional(v.string()), // Caminho do arquivo que gerou este documento
|
|
||||||
funcaoOrigem: v.optional(v.string()), // Nome da função/componente que gerou este documento
|
|
||||||
hashOrigem: v.optional(v.string()), // Hash do arquivo/função para detectar mudanças
|
|
||||||
metadados: v.optional(
|
|
||||||
v.object({
|
|
||||||
parametros: v.optional(v.array(v.string())),
|
|
||||||
retorno: v.optional(v.string()),
|
|
||||||
dependencias: v.optional(v.array(v.string())),
|
|
||||||
exemplos: v.optional(v.array(v.string())),
|
|
||||||
algoritmo: v.optional(v.string())
|
|
||||||
})
|
|
||||||
),
|
|
||||||
ativo: v.boolean(),
|
|
||||||
criadoPor: v.id('usuarios'),
|
|
||||||
criadoEm: v.number(),
|
|
||||||
atualizadoPor: v.optional(v.id('usuarios')),
|
|
||||||
atualizadoEm: v.number(),
|
|
||||||
visualizacoes: v.number(), // Contador de visualizações
|
|
||||||
geradoAutomaticamente: v.boolean() // Se foi gerado pela varredura automática
|
|
||||||
})
|
|
||||||
.index('by_categoria', ['categoriaId'])
|
|
||||||
.index('by_tipo', ['tipo'])
|
|
||||||
.index('by_busca', ['conteudoBusca'])
|
|
||||||
.index('by_ativo', ['ativo'])
|
|
||||||
.index('by_criado_em', ['criadoEm'])
|
|
||||||
.index('by_atualizado_em', ['atualizadoEm'])
|
|
||||||
.index('by_arquivo_origem', ['arquivoOrigem'])
|
|
||||||
.index('by_hash_origem', ['hashOrigem']),
|
|
||||||
|
|
||||||
// Categorias hierárquicas (Módulos, Seções, Funções)
|
|
||||||
documentacaoCategorias: defineTable({
|
|
||||||
nome: v.string(),
|
|
||||||
descricao: v.optional(v.string()),
|
|
||||||
icone: v.optional(v.string()), // Nome do ícone (lucide-svelte)
|
|
||||||
cor: v.optional(
|
|
||||||
v.union(
|
|
||||||
v.literal('primary'),
|
|
||||||
v.literal('secondary'),
|
|
||||||
v.literal('accent'),
|
|
||||||
v.literal('success'),
|
|
||||||
v.literal('warning'),
|
|
||||||
v.literal('error'),
|
|
||||||
v.literal('info')
|
|
||||||
)
|
|
||||||
),
|
|
||||||
parentId: v.optional(v.id('documentacaoCategorias')), // Para hierarquia
|
|
||||||
ordem: v.number(), // Ordem de exibição
|
|
||||||
ativo: v.boolean(),
|
|
||||||
criadoPor: v.id('usuarios'),
|
|
||||||
criadoEm: v.number(),
|
|
||||||
atualizadoEm: v.number()
|
|
||||||
})
|
|
||||||
.index('by_parent', ['parentId'])
|
|
||||||
.index('by_ordem', ['ordem'])
|
|
||||||
.index('by_ativo', ['ativo']),
|
|
||||||
|
|
||||||
// Tags para busca e organização
|
|
||||||
documentacaoTags: defineTable({
|
|
||||||
nome: v.string(),
|
|
||||||
descricao: v.optional(v.string()),
|
|
||||||
cor: v.optional(
|
|
||||||
v.union(
|
|
||||||
v.literal('primary'),
|
|
||||||
v.literal('secondary'),
|
|
||||||
v.literal('accent'),
|
|
||||||
v.literal('success'),
|
|
||||||
v.literal('warning'),
|
|
||||||
v.literal('error'),
|
|
||||||
v.literal('info')
|
|
||||||
)
|
|
||||||
),
|
|
||||||
usadoEm: v.number(), // Contador de quantos documentos usam esta tag
|
|
||||||
ativo: v.boolean(),
|
|
||||||
criadoPor: v.id('usuarios'),
|
|
||||||
criadoEm: v.number()
|
|
||||||
})
|
|
||||||
.index('by_nome', ['nome'])
|
|
||||||
.index('by_ativo', ['ativo']),
|
|
||||||
|
|
||||||
// Histórico de varreduras realizadas
|
|
||||||
documentacaoVarredura: defineTable({
|
|
||||||
tipo: v.union(v.literal('automatica'), v.literal('manual')),
|
|
||||||
status: v.union(
|
|
||||||
v.literal('em_andamento'),
|
|
||||||
v.literal('concluida'),
|
|
||||||
v.literal('erro'),
|
|
||||||
v.literal('cancelada')
|
|
||||||
),
|
|
||||||
documentosEncontrados: v.number(), // Quantidade de documentos encontrados
|
|
||||||
documentosNovos: v.number(), // Quantidade de novos documentos criados
|
|
||||||
documentosAtualizados: v.number(), // Quantidade de documentos atualizados
|
|
||||||
arquivosAnalisados: v.number(), // Quantidade de arquivos analisados
|
|
||||||
erros: v.optional(v.array(v.string())), // Lista de erros encontrados
|
|
||||||
duracaoMs: v.optional(v.number()), // Duração em milissegundos
|
|
||||||
executadoPor: v.id('usuarios'),
|
|
||||||
iniciadoEm: v.number(),
|
|
||||||
concluidoEm: v.optional(v.number())
|
|
||||||
})
|
|
||||||
.index('by_status', ['status'])
|
|
||||||
.index('by_tipo', ['tipo'])
|
|
||||||
.index('by_executado_por', ['executadoPor'])
|
|
||||||
.index('by_iniciado_em', ['iniciadoEm']),
|
|
||||||
|
|
||||||
// Configurações de agendamento de varredura
|
|
||||||
documentacaoConfig: defineTable({
|
|
||||||
ativo: v.boolean(),
|
|
||||||
diasSemana: v.array(
|
|
||||||
v.union(
|
|
||||||
v.literal('domingo'),
|
|
||||||
v.literal('segunda'),
|
|
||||||
v.literal('terca'),
|
|
||||||
v.literal('quarta'),
|
|
||||||
v.literal('quinta'),
|
|
||||||
v.literal('sexta'),
|
|
||||||
v.literal('sabado')
|
|
||||||
)
|
|
||||||
), // Dias da semana para executar varredura
|
|
||||||
horario: v.string(), // Horário no formato "HH:MM" (ex: "08:00")
|
|
||||||
fusoHorario: v.optional(v.string()), // Fuso horário (padrão: "America/Recife")
|
|
||||||
ultimaExecucao: v.optional(v.number()), // Timestamp da última execução
|
|
||||||
proximaExecucao: v.optional(v.number()), // Timestamp da próxima execução agendada
|
|
||||||
configuradoPor: v.id('usuarios'),
|
|
||||||
configuradoEm: v.number(),
|
|
||||||
atualizadoPor: v.optional(v.id('usuarios')),
|
|
||||||
atualizadoEm: v.number()
|
|
||||||
})
|
|
||||||
.index('by_ativo', ['ativo'])
|
|
||||||
};
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user