|
|
|
|
@@ -14,6 +14,7 @@
|
|
|
|
|
Search,
|
|
|
|
|
Sparkles,
|
|
|
|
|
FileCode,
|
|
|
|
|
FileText,
|
|
|
|
|
Upload
|
|
|
|
|
} from 'lucide-svelte';
|
|
|
|
|
import { toast } from 'svelte-sonner';
|
|
|
|
|
@@ -34,18 +35,109 @@
|
|
|
|
|
let searchTerm = $state('');
|
|
|
|
|
|
|
|
|
|
const isAdmin = $derived($currentUser?.roles?.includes('admin') || false);
|
|
|
|
|
const currentPath = $derived(page.url.pathname);
|
|
|
|
|
const currentPath = $derived(page.url.pathname + page.url.search);
|
|
|
|
|
|
|
|
|
|
// Filtrar artículos contextuales basados en la ruta actual
|
|
|
|
|
const synonyms: Record<string, string[]> = {
|
|
|
|
|
'users': ['usuario', 'colaborador', 'acceso', 'perfil', 'permiso', 'roles'],
|
|
|
|
|
'company': ['empresa', 'compania', 'negocio', 'fiscal', 'emisor', 'razon social', 'rfc'],
|
|
|
|
|
'invoices': ['factura', 'cobro', 'gasto', 'cxc', 'cxp', 'comprobante', 'imp', 'exp', 'facturacion'],
|
|
|
|
|
'packages': ['bulto', 'embalaje', 'empaque', 'pallet', 'contenedor', 'packing'],
|
|
|
|
|
'audit': ['auditoria', 'bitacora', 'log', 'historial', 'evento', 'monitoreo'],
|
|
|
|
|
'pedimentos': ['pedimento', 'aduana', 'valida', 'despacho', 'pedimentacion'],
|
|
|
|
|
'parts': ['parte', 'mercancia', 'producto', 'fraccion', 'item', 'articulo', 'numero de parte'],
|
|
|
|
|
'goods': ['mercancia', 'producto', 'bienes', 'parte', 'item'],
|
|
|
|
|
'manifest': ['manifiesto', 'salida', 'embarque', 'transporte', 'carga'],
|
|
|
|
|
'clients': ['cliente', 'proveedor', 'vendor', 'provider', 'comercial', 'socio', 'proveedores', 'clientes'],
|
|
|
|
|
'transporters': ['transportista', 'fletera', 'chofer', 'conductor', 'camion', 'vehiculo', 'transporte'],
|
|
|
|
|
'settings': ['configuracion', 'ajuste', 'preferencia', 'perfil', 'cuenta'],
|
|
|
|
|
'digitalizacion': ['documento', 'archivo', 'digital', 'expediente', 'pdf', 'xml', 'e-document'],
|
|
|
|
|
'reports': ['reporte', 'estadistica', 'grafica', 'consulta', 'descarga', 'excel', 'kpi'],
|
|
|
|
|
'fractions': ['fraccion', 'arancel', 'nico', 'tarifa', 'impuesto', 'tigie'],
|
|
|
|
|
'reference': ['catalogo', 'fijo', 'referencia', 'maestro', 'base']
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function getKeywords(str: string): string[] {
|
|
|
|
|
const isEdit = str.toLowerCase().includes('/edit') || str.toLowerCase().includes('/editor');
|
|
|
|
|
const isCreate = str.toLowerCase().includes('/create') || str.toLowerCase().includes('/new');
|
|
|
|
|
|
|
|
|
|
const baseKeywords = str
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.normalize("NFD").replace(/[\u0300-\u036f]/g, "") // Quitar acentos
|
|
|
|
|
.replace(/[/_-]/g, ' ')
|
|
|
|
|
.split(/\s+/)
|
|
|
|
|
.filter(w => w.length > 2 && !['dashboard', 'general', 'catalogs', 'information', 'management'].includes(w));
|
|
|
|
|
|
|
|
|
|
if (isEdit) baseKeywords.push('edicion', 'editar', 'modificar', 'actualizar');
|
|
|
|
|
if (isCreate) baseKeywords.push('crear', 'nuevo', 'registro', 'alta');
|
|
|
|
|
|
|
|
|
|
// Expandir con sinónimos
|
|
|
|
|
const expanded = [...baseKeywords];
|
|
|
|
|
baseKeywords.forEach(kw => {
|
|
|
|
|
if (synonyms[kw]) expanded.push(...synonyms[kw]);
|
|
|
|
|
// Buscar si la palabra clave es un sinónimo de alguna categoría
|
|
|
|
|
Object.entries(synonyms).forEach(([key, values]) => {
|
|
|
|
|
if (values.includes(kw)) expanded.push(key);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
return [...new Set(expanded)];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$inspect('HELP_DEBUG_PATH', currentPath);
|
|
|
|
|
$inspect('HELP_DEBUG_KEYWORDS', getKeywords(currentPath));
|
|
|
|
|
|
|
|
|
|
// Filtrar artículos contextuales basados en la ruta actual o coincidencias inteligentes
|
|
|
|
|
const contextualArticles = $derived(
|
|
|
|
|
articles.filter((a) => {
|
|
|
|
|
if (!a.context_path) return false;
|
|
|
|
|
try {
|
|
|
|
|
const regex = new RegExp(a.context_path);
|
|
|
|
|
return regex.test(currentPath);
|
|
|
|
|
} catch {
|
|
|
|
|
return currentPath.includes(a.context_path);
|
|
|
|
|
// 1. Prioridad: Ruta explícita (si existe)
|
|
|
|
|
if (a.context_path) {
|
|
|
|
|
try {
|
|
|
|
|
if (a.context_path.startsWith('/')) {
|
|
|
|
|
if (currentPath === a.context_path || currentPath.startsWith(a.context_path + '/')) return true;
|
|
|
|
|
} else {
|
|
|
|
|
const regex = new RegExp(a.context_path);
|
|
|
|
|
if (regex.test(currentPath)) return true;
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
if (currentPath.includes(a.context_path)) return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. Inteligencia de Coincidencia (Fuzzy match por palabras clave)
|
|
|
|
|
const editIntents = ['edicion', 'editar', 'modificar', 'actualizar', 'corregir', 'edit', 'editor'];
|
|
|
|
|
const createIntents = ['crear', 'nuevo', 'registro', 'alta', 'create', 'new'];
|
|
|
|
|
const intentionWords = [...editIntents, ...createIntents, 'baja', 'cambio'];
|
|
|
|
|
|
|
|
|
|
const pathKeywords = getKeywords(currentPath);
|
|
|
|
|
const moduleKeywords = pathKeywords.filter(pk => !intentionWords.includes(pk));
|
|
|
|
|
const pathIntentKeywords = pathKeywords.filter(pk => intentionWords.includes(pk));
|
|
|
|
|
|
|
|
|
|
const titleKeywords = getKeywords(a.title);
|
|
|
|
|
const contentKeywords = getKeywords(a.content.substring(0, 100));
|
|
|
|
|
|
|
|
|
|
// Si estamos en un módulo específico (ej. Pedimentos), DEBE coincidir el módulo
|
|
|
|
|
const matchesModule = moduleKeywords.length === 0 || moduleKeywords.some(pk =>
|
|
|
|
|
titleKeywords.some(tk => tk.includes(pk) || pk.includes(tk)) ||
|
|
|
|
|
contentKeywords.some(ck => ck.includes(pk) || pk.includes(ck))
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!matchesModule) return false;
|
|
|
|
|
|
|
|
|
|
// Si hay una intención clara en la URL (crear/editar), filtramos los artículos
|
|
|
|
|
// que sean explícitamente de la intención OPUESTA.
|
|
|
|
|
if (pathIntentKeywords.length > 0) {
|
|
|
|
|
const pathIsEdit = pathIntentKeywords.some(pk => editIntents.includes(pk));
|
|
|
|
|
const pathIsCreate = pathIntentKeywords.some(pk => createIntents.includes(pk));
|
|
|
|
|
|
|
|
|
|
const articleIsEdit = titleKeywords.some(tk => editIntents.includes(tk));
|
|
|
|
|
const articleIsCreate = titleKeywords.some(tk => createIntents.includes(tk));
|
|
|
|
|
|
|
|
|
|
// Bloqueo cruzado: Si estoy creando, no me des manuales que son SOLO de editar.
|
|
|
|
|
// (Si el manual sirve para ambos o es general, pasará)
|
|
|
|
|
if (pathIsCreate && articleIsEdit && !articleIsCreate) return false;
|
|
|
|
|
if (pathIsEdit && articleIsCreate && !articleIsEdit) return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
@@ -87,7 +179,7 @@
|
|
|
|
|
|
|
|
|
|
function startCreate() {
|
|
|
|
|
editContent = '# Nuevo Artículo\nEscribe el contenido aquí...';
|
|
|
|
|
editTitle = '';
|
|
|
|
|
editTitle = 'Nueva Guía de Ayuda';
|
|
|
|
|
isEditing = true;
|
|
|
|
|
isCreating = true;
|
|
|
|
|
selectedArticle = {
|
|
|
|
|
@@ -107,6 +199,7 @@
|
|
|
|
|
const newArticle = await helpApi.createArticle({
|
|
|
|
|
title: editTitle,
|
|
|
|
|
content: editContent,
|
|
|
|
|
context_path: currentPath, // Captura automática de la ruta real
|
|
|
|
|
last_editor: $currentUser?.username || 'unknown'
|
|
|
|
|
});
|
|
|
|
|
selectedArticle = newArticle;
|
|
|
|
|
@@ -144,6 +237,13 @@
|
|
|
|
|
.replace(/\*(.*)\*/gim, '<em>$1</em>')
|
|
|
|
|
.replace(/\n/gim, '<br />');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function highlightText(text: string, term: string) {
|
|
|
|
|
if (!term || term.length < 2) return text;
|
|
|
|
|
const escapedTerm = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
|
const regex = new RegExp(`(${escapedTerm})`, 'gi');
|
|
|
|
|
return text.replace(regex, '<mark class="bg-primary/20 text-primary-foreground font-bold rounded-sm px-0.5">$1</mark>');
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<Sheet.Root bind:open={helpStore.isOpen}>
|
|
|
|
|
@@ -176,20 +276,26 @@
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Sección Contextual -->
|
|
|
|
|
<!-- Sección Contextual (Recomendaciones) -->
|
|
|
|
|
{#if contextualArticles.length > 0 && !searchTerm}
|
|
|
|
|
<div class="space-y-3">
|
|
|
|
|
<div class="flex items-center gap-2 px-1">
|
|
|
|
|
<Badge variant="secondary" class="bg-primary/10 text-primary border-primary/20">Recomendado para esta pantalla</Badge>
|
|
|
|
|
<div class="flex items-center gap-1.5 text-xs font-semibold text-primary uppercase tracking-wider">
|
|
|
|
|
<Sparkles size={14} /> Recomendado para ti
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="grid gap-3">
|
|
|
|
|
{#each contextualArticles as article}
|
|
|
|
|
<button
|
|
|
|
|
onclick={() => selectArticle(article)}
|
|
|
|
|
class="flex items-start gap-4 p-4 rounded-xl border bg-card hover:bg-accent/50 hover:border-primary/30 transition-all text-left shadow-sm hover:shadow-md"
|
|
|
|
|
class="flex items-start gap-4 p-4 rounded-xl border bg-primary/5 border-primary/20 hover:bg-primary/10 hover:border-primary/30 transition-all text-left shadow-sm"
|
|
|
|
|
>
|
|
|
|
|
<div class="p-2 rounded-lg bg-muted flex-shrink-0">
|
|
|
|
|
<BookOpen size={18} class="text-primary" />
|
|
|
|
|
<div class="p-2 rounded-lg bg-background flex-shrink-0 shadow-sm">
|
|
|
|
|
{#if article.content_type === 'pdf'}
|
|
|
|
|
<FileText size={18} class="text-primary" />
|
|
|
|
|
{:else}
|
|
|
|
|
<BookOpen size={18} class="text-primary" />
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="space-y-1">
|
|
|
|
|
<h4 class="font-semibold text-sm leading-tight">{article.title}</h4>
|
|
|
|
|
@@ -201,44 +307,72 @@
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<!-- Todos los artículos -->
|
|
|
|
|
<div class="space-y-3">
|
|
|
|
|
<h3 class="text-sm font-medium text-muted-foreground px-1">Guías Disponibles</h3>
|
|
|
|
|
<!-- Catálogo Completo / Búsqueda -->
|
|
|
|
|
<div class="space-y-4">
|
|
|
|
|
<div class="flex items-center justify-between px-1">
|
|
|
|
|
<h3 class="text-xs font-bold text-muted-foreground uppercase tracking-widest">
|
|
|
|
|
{searchTerm ? 'Resultados de búsqueda' : 'Manuales del Sistema'}
|
|
|
|
|
</h3>
|
|
|
|
|
{#if articles.length > 0}
|
|
|
|
|
<span class="text-[10px] text-muted-foreground bg-muted px-2 py-0.5 rounded-full">{articles.length} artículos</span>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{#if isLoading}
|
|
|
|
|
<div class="flex justify-center py-12">
|
|
|
|
|
<div class="flex flex-col items-center justify-center py-12 space-y-4">
|
|
|
|
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
|
|
|
|
<p class="text-xs text-muted-foreground animate-pulse">Cargando base de conocimientos...</p>
|
|
|
|
|
</div>
|
|
|
|
|
{:else if filteredArticles.length === 0}
|
|
|
|
|
<div class="text-center py-12 px-4 rounded-xl border border-dashed">
|
|
|
|
|
<Search size={32} class="mx-auto mb-3 text-muted-foreground opacity-20" />
|
|
|
|
|
<p class="text-sm text-muted-foreground">No encontramos nada que coincida con tu búsqueda.</p>
|
|
|
|
|
{:else if articles.length === 0}
|
|
|
|
|
<div class="flex flex-col items-center justify-center py-12 px-4 text-center space-y-3 bg-muted/20 rounded-2xl border border-dashed border-muted-foreground/20">
|
|
|
|
|
<div class="p-3 rounded-full bg-background shadow-sm">
|
|
|
|
|
<BookOpen size={24} class="text-muted-foreground/40" />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="space-y-1">
|
|
|
|
|
<p class="text-sm font-medium">Biblioteca vacía</p>
|
|
|
|
|
<p class="text-xs text-muted-foreground">No hay artículos registrados en el sistema aún.</p>
|
|
|
|
|
</div>
|
|
|
|
|
{#if isAdmin}
|
|
|
|
|
<Button variant="outline" size="sm" onclick={startCreate} class="mt-2 h-8">
|
|
|
|
|
<Plus size={14} class="mr-1" /> Crear primero
|
|
|
|
|
</Button>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
{:else}
|
|
|
|
|
<div class="grid gap-2">
|
|
|
|
|
{#each filteredArticles as article}
|
|
|
|
|
<button
|
|
|
|
|
onclick={() => selectArticle(article)}
|
|
|
|
|
class="flex items-center justify-between p-3.5 rounded-lg border border-transparent hover:border-border hover:bg-muted/30 transition-all text-left group"
|
|
|
|
|
>
|
|
|
|
|
<div class="flex items-center gap-3">
|
|
|
|
|
<div class="w-8 h-8 rounded-md bg-muted flex items-center justify-center text-muted-foreground group-hover:text-primary transition-colors shrink-0">
|
|
|
|
|
<BookOpen size={16} />
|
|
|
|
|
{@const displayList = searchTerm ? filteredArticles : articles}
|
|
|
|
|
{#if displayList.length === 0}
|
|
|
|
|
<div class="text-center py-12 px-4 rounded-xl border border-dashed bg-muted/10">
|
|
|
|
|
<Search size={32} class="mx-auto mb-3 text-muted-foreground opacity-20" />
|
|
|
|
|
<p class="text-sm text-muted-foreground">No encontramos nada para "{searchTerm}"</p>
|
|
|
|
|
<Button variant="link" size="sm" onclick={() => searchTerm = ''}>Limpiar búsqueda</Button>
|
|
|
|
|
</div>
|
|
|
|
|
{:else}
|
|
|
|
|
<div class="grid gap-2">
|
|
|
|
|
{#each displayList as article}
|
|
|
|
|
<button
|
|
|
|
|
onclick={() => selectArticle(article)}
|
|
|
|
|
class="flex items-center justify-between p-3.5 rounded-xl border border-transparent hover:border-border hover:bg-muted/50 transition-all text-left group"
|
|
|
|
|
>
|
|
|
|
|
<div class="flex items-center gap-3 overflow-hidden">
|
|
|
|
|
<div class="w-9 h-9 rounded-lg bg-muted flex items-center justify-center text-muted-foreground group-hover:text-primary group-hover:bg-primary/10 transition-all shrink-0">
|
|
|
|
|
{#if article.content_type === 'pdf'}
|
|
|
|
|
<FileText size={16} />
|
|
|
|
|
{:else}
|
|
|
|
|
<BookOpen size={16} />
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex flex-col overflow-hidden">
|
|
|
|
|
<span class="text-sm font-medium truncate group-hover:text-primary transition-colors">
|
|
|
|
|
{@html highlightText(article.title, searchTerm)}
|
|
|
|
|
</span>
|
|
|
|
|
<span class="text-[10px] text-muted-foreground uppercase">{article.category || 'General'}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="flex flex-col">
|
|
|
|
|
<span class="text-sm font-medium">{article.title}</span>
|
|
|
|
|
{#if article.tags}
|
|
|
|
|
<div class="flex flex-wrap gap-1 mt-1">
|
|
|
|
|
{#each article.tags.split(',') as tag}
|
|
|
|
|
<span class="text-[9px] text-muted-foreground bg-muted/50 px-1 rounded border border-border/50">{tag.trim()}</span>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<Badge variant="outline" class="text-[10px] opacity-60 uppercase tracking-tighter shrink-0 ml-2">{article.category || 'Gral'}</Badge>
|
|
|
|
|
</button>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
<ChevronLeft class="rotate-180 opacity-0 group-hover:opacity-40 transition-all w-4 h-4 shrink-0" />
|
|
|
|
|
</button>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
@@ -311,10 +445,20 @@
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="prose prose-sm dark:prose-invert max-w-none prose-headings:text-foreground prose-p:text-muted-foreground prose-strong:text-foreground leading-relaxed">
|
|
|
|
|
{#if browser}
|
|
|
|
|
{@html renderMarkdown(selectedArticle.content)}
|
|
|
|
|
{#if selectedArticle.content_type === 'pdf' && selectedArticle.file_url}
|
|
|
|
|
<div class="rounded-2xl overflow-hidden border bg-muted/30 h-[calc(100vh-350px)] min-h-[500px] shadow-sm">
|
|
|
|
|
<iframe
|
|
|
|
|
src={selectedArticle.file_url}
|
|
|
|
|
class="w-full h-full border-none"
|
|
|
|
|
title={selectedArticle.title}
|
|
|
|
|
></iframe>
|
|
|
|
|
</div>
|
|
|
|
|
{:else}
|
|
|
|
|
<div class="whitespace-pre-wrap">{selectedArticle.content}</div>
|
|
|
|
|
{#if browser}
|
|
|
|
|
{@html renderMarkdown(selectedArticle.content)}
|
|
|
|
|
{:else}
|
|
|
|
|
<div class="whitespace-pre-wrap">{selectedArticle.content}</div>
|
|
|
|
|
{/if}
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|