Files
plantillas-proyectos/frontend/src/lib/components/help/HelpDrawer.svelte

508 lines
20 KiB
Svelte

<script lang="ts">
import { currentUser } from '$lib/auth';
import * as Sheet from '$lib/components/ui/sheet';
import { Button } from '$lib/components/ui/button';
import {
HelpCircle,
Edit2,
Save,
X,
ChevronLeft,
Plus,
BookOpen,
ExternalLink,
Search,
Sparkles,
FileCode,
FileText,
Upload
} from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { helpApi, type HelpArticle } from '$lib/api/help';
import { helpStore } from '$lib/stores/help.svelte';
import { browser } from '$app/environment';
import { page } from '$app/state';
import { Badge } from '$lib/components/ui/badge';
import { Label } from '$lib/components/ui/label';
let articles = $state<HelpArticle[]>([]);
let selectedArticle = $state<HelpArticle | null>(null);
let isEditing = $state(false);
let editContent = $state('');
let editTitle = $state('');
let isLoading = $state(false);
let isCreating = $state(false);
let searchTerm = $state('');
let hasError = $state(false);
let isLoaded = $state(false);
const isAdmin = $derived($currentUser?.roles?.includes('admin') || false);
const currentPath = $derived(page.url.pathname + page.url.search);
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) => {
// 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;
})
);
// Filtrar búsqueda manual
const filteredArticles = $derived(
articles.filter(
(a) =>
a.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
(a.category || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
(a.tags || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
(a.content || '').toLowerCase().includes(searchTerm.toLowerCase())
)
);
async function loadArticles() {
isLoading = true;
try {
hasError = false;
articles = await helpApi.listArticles();
isLoaded = true;
} catch (e) {
hasError = true;
toast.error('Error al cargar artículos de ayuda');
} finally {
isLoading = false;
}
}
function selectArticle(article: HelpArticle) {
selectedArticle = article;
isEditing = false;
isCreating = false;
}
function startEdit() {
if (!selectedArticle) return;
editContent = selectedArticle.content;
editTitle = selectedArticle.title;
isEditing = true;
isCreating = false;
}
function startCreate() {
editContent = '# Nuevo Artículo\nEscribe el contenido aquí...';
editTitle = 'Nueva Guía de Ayuda';
isEditing = true;
isCreating = true;
selectedArticle = {
uuid: '',
slug: '',
title: '',
content: '',
updated_at: '',
last_editor: '',
content_type: 'markdown'
} as any;
}
async function saveChanges() {
try {
if (isCreating) {
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;
toast.success('Artículo creado con éxito');
} else if (selectedArticle) {
const updated = await helpApi.updateArticle(selectedArticle.uuid, {
content: editContent,
title: editTitle,
last_editor: $currentUser?.username || 'unknown'
});
selectedArticle = updated;
toast.success('Cambios guardados');
}
isEditing = false;
isCreating = false;
loadArticles();
} catch (e) {
toast.error('Error al guardar cambios');
}
}
$effect(() => {
if (helpStore.isOpen && !isLoaded && !isLoading && !hasError) {
loadArticles();
}
// Reset error when drawer closes to allow retry next time it opens
if (!helpStore.isOpen) {
hasError = false;
}
});
function renderMarkdown(content: string) {
// Fallback simple pero limpio
return content
.replace(/^# (.*$)/gim, '<h1 class="text-2xl font-bold mb-4">$1</h1>')
.replace(/^## (.*$)/gim, '<h2 class="text-xl font-bold mb-3 mt-6">$1</h2>')
.replace(/^### (.*$)/gim, '<h3 class="text-lg font-bold mb-2 mt-4">$1</h3>')
.replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
.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}>
<Sheet.Content side="right" class="w-[450px] sm:w-[600px] border-l bg-background/95 backdrop-blur-md p-0 shadow-2xl flex flex-col">
<!-- Header con Glassmorphism -->
<div class="px-6 py-8 border-b bg-primary/5">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
{#if selectedArticle}
<Button variant="ghost" size="icon" onclick={() => (selectedArticle = null)} class="mr-1 h-8 w-8 rounded-full">
<ChevronLeft size={20} />
</Button>
{/if}
<div class="p-2 rounded-lg bg-primary/10 text-primary">
<Sparkles size={20} />
</div>
<h2 class="text-xl font-bold tracking-tight">
{selectedArticle ? 'Artículo de Ayuda' : 'Centro de Ayuda'}
</h2>
</div>
</div>
<p class="text-sm text-muted-foreground">Manuales y guías interactivas del sistema.</p>
</div>
<div class="flex-1 flex flex-col overflow-hidden">
{#if !selectedArticle}
<!-- Vista de Lista -->
<div class="p-6 flex-1 overflow-y-auto space-y-8">
<!-- Search Box -->
<div class="relative group">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground group-focus-within:text-primary transition-colors" />
<input
type="text"
placeholder="Buscar ayuda..."
bind:value={searchTerm}
class="w-full pl-10 pr-4 py-2.5 rounded-xl border bg-muted/50 focus:bg-background focus:ring-2 focus:ring-primary/20 outline-none transition-all"
/>
</div>
<!-- Sección Contextual (Recomendaciones) -->
{#if contextualArticles.length > 0 && !searchTerm}
<div class="space-y-3">
<div class="flex items-center gap-2 px-1">
<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-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-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>
<p class="text-xs text-muted-foreground line-clamp-1">{article.category || 'General'}</p>
</div>
</button>
{/each}
</div>
</div>
{/if}
<!-- 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 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 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">
{hasError ? 'Error de conexión' : 'Biblioteca vacía'}
</p>
<p class="text-xs text-muted-foreground">
{hasError
? 'No pudimos conectar con el servidor de ayuda.'
: 'No hay artículos registrados para esta sección aún.'}
</p>
</div>
{#if hasError}
<Button variant="outline" size="sm" onclick={loadArticles} class="mt-2 h-8">
Reintentar
</Button>
{:else 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}
{@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>
<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>
<!-- Footer Actions -->
<div class="p-6 border-t bg-muted/20">
<div class="flex flex-col gap-3">
<Button href="/dashboard/help-center" variant="outline" class="w-full bg-background rounded-xl h-11 border-primary/20 text-primary hover:bg-primary/5">
<ExternalLink size={16} class="mr-2" /> Ir al Catálogo de Manuales
</Button>
{#if isAdmin}
<Button onclick={startCreate} variant="ghost" class="w-full rounded-xl h-11 text-muted-foreground hover:text-foreground">
<Plus size={16} class="mr-2" /> Crear nuevo artículo
</Button>
{/if}
</div>
</div>
{:else}
<!-- Vista de Artículo -->
<div class="flex-1 flex flex-col overflow-hidden">
<div class="px-6 py-4 flex items-center justify-between border-b bg-background">
<Button variant="ghost" size="sm" onclick={() => (selectedArticle = null)} class="rounded-lg h-9">
<ChevronLeft size={18} class="mr-1" /> Volver
</Button>
{#if isAdmin && !isEditing}
<Button variant="outline" size="sm" onclick={startEdit} class="h-9 rounded-lg">
<Edit2 size={16} class="mr-2" /> Editar
</Button>
{/if}
</div>
<div class="flex-1 overflow-y-auto p-8">
{#if isEditing}
<div class="space-y-6">
<div class="space-y-2">
<Label class="text-xs uppercase tracking-widest text-muted-foreground">Título</Label>
<input
bind:value={editTitle}
class="w-full bg-transparent border-b border-muted py-2 text-2xl font-bold outline-none focus:border-primary transition-colors"
placeholder="Título del artículo"
/>
</div>
<div class="space-y-2">
<Label class="text-xs uppercase tracking-widest text-muted-foreground">Contenido (Markdown)</Label>
<textarea
bind:value={editContent}
class="min-h-[500px] w-full bg-muted/20 rounded-xl border p-6 font-mono text-sm focus:ring-2 focus:ring-primary/20 outline-none transition-all"
placeholder="Escribe el contenido..."
></textarea>
</div>
<div class="flex justify-end gap-3 pt-4">
<Button variant="outline" onclick={() => {
isEditing = false;
if (isCreating) selectedArticle = null;
isCreating = false;
}} class="rounded-xl">Cancelar</Button>
<Button onclick={saveChanges} class="rounded-xl px-8 shadow-lg shadow-primary/20">Guardar Cambios</Button>
</div>
</div>
{:else}
<div class="max-w-2xl mx-auto space-y-6">
<div class="space-y-2">
<Badge variant="outline" class="bg-primary/5 text-primary border-primary/20">{selectedArticle.category || 'General'}</Badge>
<h1 class="text-4xl font-extrabold tracking-tight">{selectedArticle.title}</h1>
<div class="flex items-center gap-4 text-xs text-muted-foreground pt-1 border-b pb-6">
<span>Por <strong>{selectedArticle.last_editor}</strong></span>
<span></span>
<span>Actualizado: {new Date(selectedArticle.updated_at).toLocaleDateString()}</span>
</div>
</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 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}
{#if browser}
{@html renderMarkdown(selectedArticle.content)}
{:else}
<div class="whitespace-pre-wrap">{selectedArticle.content}</div>
{/if}
{/if}
</div>
</div>
{/if}
</div>
</div>
{/if}
</div>
</Sheet.Content>
</Sheet.Root>
<style>
@reference "../../../app.css";
:global(.prose h1) { @apply text-3xl font-bold mb-6 text-foreground; }
:global(.prose h2) { @apply text-2xl font-semibold mb-4 mt-8 text-foreground; }
:global(.prose p) { @apply mb-4 text-muted-foreground; }
:global(.prose strong) { @apply font-bold text-foreground; }
</style>