Merge branch 'development' of https://git.aduanasoft.com/ADUANASOFT/anexo76 into fix/facturas-ame-mex-2
This commit is contained in:
@@ -14,6 +14,8 @@ class HelpArticleBase(BaseModel):
|
||||
file_url: Optional[str] = None
|
||||
file_size: Optional[int] = None
|
||||
mime_type: Optional[str] = None
|
||||
context_path: Optional[str] = None
|
||||
tags: Optional[str] = None
|
||||
|
||||
class HelpArticleCreate(HelpArticleBase):
|
||||
pass
|
||||
@@ -29,6 +31,8 @@ class HelpArticleUpdate(BaseModel):
|
||||
file_url: Optional[str] = None
|
||||
file_size: Optional[int] = None
|
||||
mime_type: Optional[str] = None
|
||||
context_path: Optional[str] = None
|
||||
tags: Optional[str] = None
|
||||
|
||||
class HelpArticleInDB(HelpArticleBase):
|
||||
uuid: UUID
|
||||
@@ -50,6 +54,8 @@ class HelpSyncRequest(BaseModel):
|
||||
client_file_url: Optional[str] = None
|
||||
client_file_size: Optional[int] = None
|
||||
client_mime_type: Optional[str] = None
|
||||
client_context_path: Optional[str] = None
|
||||
client_tags: Optional[str] = None
|
||||
|
||||
class HelpSyncResponse(BaseModel):
|
||||
status: str
|
||||
@@ -63,4 +69,6 @@ class HelpSyncResponse(BaseModel):
|
||||
server_file_url: Optional[str] = None
|
||||
server_file_size: Optional[int] = None
|
||||
server_mime_type: Optional[str] = None
|
||||
server_context_path: Optional[str] = None
|
||||
server_tags: Optional[str] = None
|
||||
message: str
|
||||
|
||||
@@ -22,6 +22,8 @@ class HelpCenterService:
|
||||
article.file_url = metadata.get("file_url")
|
||||
article.file_size = metadata.get("file_size")
|
||||
article.mime_type = metadata.get("mime_type")
|
||||
article.context_path = metadata.get("context_path")
|
||||
article.tags = metadata.get("tags")
|
||||
# Remove metadata from content for clean display if needed,
|
||||
# but usually better to leave it and let parser handle it or hide it here.
|
||||
# For now, we just set the attributes.
|
||||
@@ -32,6 +34,8 @@ class HelpCenterService:
|
||||
article.file_url = None
|
||||
article.file_size = None
|
||||
article.mime_type = None
|
||||
article.context_path = None
|
||||
article.tags = None
|
||||
|
||||
return article
|
||||
|
||||
@@ -44,11 +48,16 @@ class HelpCenterService:
|
||||
"content_type": data.get("content_type", "article"),
|
||||
"file_url": data.get("file_url"),
|
||||
"file_size": data.get("file_size"),
|
||||
"mime_type": data.get("mime_type")
|
||||
"mime_type": data.get("mime_type"),
|
||||
"context_path": data.get("context_path"),
|
||||
"tags": data.get("tags")
|
||||
}
|
||||
|
||||
# Only append if there's something meaningful beyond "article"
|
||||
if metadata["content_type"] != "article" or metadata["file_url"]:
|
||||
if (metadata["content_type"] != "article" or
|
||||
metadata["file_url"] or
|
||||
metadata["context_path"] or
|
||||
metadata["tags"]):
|
||||
content += f"\n\n<!-- a76_metadata: {json.dumps(metadata)} -->"
|
||||
|
||||
return content
|
||||
@@ -82,7 +91,7 @@ class HelpCenterService:
|
||||
# Move metadata into content
|
||||
data["content"] = HelpCenterService._extract_metadata(data["content"], data)
|
||||
# Remove virtual fields from data to avoid SQLAlchemy errors
|
||||
virtual_fields = ["content_type", "file_url", "file_size", "mime_type"]
|
||||
virtual_fields = ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"]
|
||||
for f in virtual_fields:
|
||||
if f in data:
|
||||
del data[f]
|
||||
@@ -105,16 +114,18 @@ class HelpCenterService:
|
||||
update_data = article_data.model_dump(exclude_unset=True)
|
||||
|
||||
# Handle metadata update
|
||||
if "content" in update_data or any(f in update_data for f in ["content_type", "file_url", "file_size", "mime_type"]):
|
||||
if "content" in update_data or any(f in update_data for f in ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"]):
|
||||
# Merge existing metadata with new updates
|
||||
current_meta = {
|
||||
"content_type": getattr(db_article, "content_type", "article"),
|
||||
"file_url": getattr(db_article, "file_url", None),
|
||||
"file_size": getattr(db_article, "file_size", None),
|
||||
"mime_type": getattr(db_article, "mime_type", None)
|
||||
"mime_type": getattr(db_article, "mime_type", None),
|
||||
"context_path": getattr(db_article, "context_path", None),
|
||||
"tags": getattr(db_article, "tags", None)
|
||||
}
|
||||
# Update with new data if present
|
||||
for f in ["content_type", "file_url", "file_size", "mime_type"]:
|
||||
for f in ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"]:
|
||||
if f in update_data:
|
||||
current_meta[f] = update_data[f]
|
||||
|
||||
@@ -123,7 +134,7 @@ class HelpCenterService:
|
||||
update_data["content"] = HelpCenterService._extract_metadata(content, current_meta)
|
||||
|
||||
# Remove virtual fields from data
|
||||
virtual_fields = ["content_type", "file_url", "file_size", "mime_type"]
|
||||
virtual_fields = ["content_type", "file_url", "file_size", "mime_type", "context_path", "tags"]
|
||||
for f in virtual_fields:
|
||||
if f in update_data:
|
||||
del update_data[f]
|
||||
@@ -162,7 +173,9 @@ class HelpCenterService:
|
||||
"content_type": sync_data.client_content_type,
|
||||
"file_url": sync_data.client_file_url,
|
||||
"file_size": sync_data.client_file_size,
|
||||
"mime_type": sync_data.client_mime_type
|
||||
"mime_type": sync_data.client_mime_type,
|
||||
"context_path": sync_data.client_context_path,
|
||||
"tags": sync_data.client_tags
|
||||
}
|
||||
content_with_meta = HelpCenterService._extract_metadata(sync_data.client_content, client_meta)
|
||||
|
||||
@@ -197,7 +210,9 @@ class HelpCenterService:
|
||||
"content_type": sync_data.client_content_type,
|
||||
"file_url": sync_data.client_file_url,
|
||||
"file_size": sync_data.client_file_size,
|
||||
"mime_type": sync_data.client_mime_type
|
||||
"mime_type": sync_data.client_mime_type,
|
||||
"context_path": sync_data.client_context_path,
|
||||
"tags": sync_data.client_tags
|
||||
}
|
||||
db_article.content = HelpCenterService._extract_metadata(sync_data.client_content, client_meta)
|
||||
db_article.title = sync_data.client_title
|
||||
@@ -232,6 +247,8 @@ class HelpCenterService:
|
||||
server_file_url=getattr(db_article, "file_url", None),
|
||||
server_file_size=getattr(db_article, "file_size", None),
|
||||
server_mime_type=getattr(db_article, "mime_type", None),
|
||||
server_context_path=getattr(db_article, "context_path", None),
|
||||
server_tags=getattr(db_article, "tags", None),
|
||||
message="Client is outdated. Update required."
|
||||
)
|
||||
|
||||
|
||||
@@ -600,6 +600,10 @@ def validate_company_access(
|
||||
.first()
|
||||
)
|
||||
|
||||
if not company:
|
||||
print(f"DEBUG: validate_company_access: No se encontró la compañía {company_id} para el tenant {tenant_id}")
|
||||
logger.warning(f"validate_company_access: No se encontró la compañía {company_id} para el tenant {tenant_id}")
|
||||
|
||||
return company is not None
|
||||
except Exception as e:
|
||||
logger.error("Error validating company access: %s", e)
|
||||
|
||||
@@ -65,8 +65,8 @@ async def on_startup():
|
||||
if settings.DEBUG:
|
||||
app.add_middleware(RequestLoggingMiddleware)
|
||||
|
||||
app.add_middleware(LicenseValidationMiddleware)
|
||||
app.add_middleware(TenantMiddleware)
|
||||
app.add_middleware(LicenseValidationMiddleware)
|
||||
app.add_middleware(UserContextMiddleware)
|
||||
|
||||
# CORS debe ser el último en añadirse para que sea el más externo
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"hello_world": "Hello, {name} from en!",
|
||||
"exchange_rate_error_title": "Exchange Rate Error",
|
||||
"dashboard": {
|
||||
"greeting_morning": "Good morning",
|
||||
"greeting_afternoon": "Good afternoon",
|
||||
@@ -103,10 +104,11 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Dashboard",
|
||||
"help_center": "System Manuals",
|
||||
"management_label": "Management",
|
||||
"bulk_upload": {
|
||||
"title": "Bulk uploads",
|
||||
"entry": "CSV import"
|
||||
"title": "Bulk Uploads",
|
||||
"entry": "CSV Import"
|
||||
},
|
||||
"reference_data": {
|
||||
"title": "Fixed Catalogs",
|
||||
@@ -1800,4 +1802,4 @@
|
||||
"download_csv": "Download CSV"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"hello_world": "Hello, {name} from es!",
|
||||
"exchange_rate_error_title": "Error de Tipo de Cambio",
|
||||
"dashboard": {
|
||||
"greeting_morning": "Buenos días",
|
||||
"greeting_afternoon": "Buenas tardes",
|
||||
@@ -103,6 +104,7 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"dashboard": "Dashboard",
|
||||
"help_center": "Manuales del Sistema",
|
||||
"management_label": "Gestión",
|
||||
"bulk_upload": {
|
||||
"title": "Cargas masivas",
|
||||
|
||||
@@ -16,6 +16,7 @@ function normalizeAbsoluteApiBaseUrl(raw: string): string {
|
||||
if (s.startsWith('https:') && !s.startsWith('https://')) {
|
||||
s = 'https://' + s.slice('https:'.length).replace(/^\/+/, '');
|
||||
}
|
||||
if (s.startsWith('/')) return s;
|
||||
if (/^https?:\/\//i.test(s)) return s;
|
||||
return `http://${s.replace(/^\/+/, '')}`;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface HelpArticle {
|
||||
file_url?: string;
|
||||
file_size?: number;
|
||||
mime_type?: string;
|
||||
context_path?: string;
|
||||
tags?: string;
|
||||
}
|
||||
|
||||
export const helpApi = {
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { authStore, currentUser } from '$lib/auth';
|
||||
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 } from 'lucide-svelte';
|
||||
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';
|
||||
|
||||
// Nota: Estas librerías deben ser instaladas: npm install marked dompurify @types/dompurify
|
||||
// Si no están, fallará el import. El usuario debe instalarlas.
|
||||
// import { marked } from 'marked';
|
||||
// import DOMPurify from 'dompurify';
|
||||
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);
|
||||
@@ -20,15 +32,136 @@
|
||||
let editTitle = $state('');
|
||||
let isLoading = $state(false);
|
||||
let isCreating = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let hasError = $state(false);
|
||||
let isLoaded = $state(false);
|
||||
|
||||
// Temporalmente habilitado para todos los usuarios por petición
|
||||
const isAdmin = $derived(true);
|
||||
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;
|
||||
@@ -51,7 +184,7 @@
|
||||
|
||||
function startCreate() {
|
||||
editContent = '# Nuevo Artículo\nEscribe el contenido aquí...';
|
||||
editTitle = '';
|
||||
editTitle = 'Nueva Guía de Ayuda';
|
||||
isEditing = true;
|
||||
isCreating = true;
|
||||
selectedArticle = {
|
||||
@@ -62,28 +195,20 @@
|
||||
updated_at: '',
|
||||
last_editor: '',
|
||||
content_type: 'markdown'
|
||||
}; // Mock for UI
|
||||
}
|
||||
|
||||
function generateSlug(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w ]+/g, '')
|
||||
.replace(/ +/g, '-');
|
||||
} as any;
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
try {
|
||||
if (isCreating) {
|
||||
const slug = generateSlug(editTitle);
|
||||
const newArticle = await helpApi.createArticle({
|
||||
title: editTitle,
|
||||
content: editContent,
|
||||
slug: slug,
|
||||
context_path: currentPath, // Captura automática de la ruta real
|
||||
last_editor: $currentUser?.username || 'unknown'
|
||||
});
|
||||
selectedArticle = newArticle;
|
||||
toast.success('Capítulo creado con éxito');
|
||||
toast.success('Artículo creado con éxito');
|
||||
} else if (selectedArticle) {
|
||||
const updated = await helpApi.updateArticle(selectedArticle.uuid, {
|
||||
content: editContent,
|
||||
@@ -91,149 +216,292 @@
|
||||
last_editor: $currentUser?.username || 'unknown'
|
||||
});
|
||||
selectedArticle = updated;
|
||||
toast.success('Cambios guardados y sincronizados');
|
||||
toast.success('Cambios guardados');
|
||||
}
|
||||
isEditing = false;
|
||||
isCreating = false;
|
||||
loadArticles();
|
||||
} catch (e) {
|
||||
toast.error(isCreating ? 'Error al crear artículo' : 'Error al guardar cambios');
|
||||
toast.error('Error al guardar cambios');
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (helpStore.isOpen && articles.length === 0 && !isLoading) {
|
||||
if (helpStore.isOpen && !isLoaded && !isLoading && !hasError) {
|
||||
loadArticles();
|
||||
}
|
||||
|
||||
// Reset error when drawer closes to allow retry next time it opens
|
||||
if (!helpStore.isOpen) {
|
||||
hasError = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Simple markdown renderer fallback if marked is not available
|
||||
function renderMarkdown(content: string) {
|
||||
// En una implementación real, usar marked + DOMPurify
|
||||
// return DOMPurify.sanitize(marked.parse(content));
|
||||
|
||||
// Fallback ultra-básico para demostración
|
||||
// Fallback simple pero limpio
|
||||
return content
|
||||
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
||||
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||||
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||||
.replace(/\*\*(.*)\*\*/gim, '<b>$1</b>')
|
||||
.replace(/\*(.*)\*/gim, '<i>$1</i>')
|
||||
.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.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
class="fixed right-6 bottom-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-110 active:scale-95"
|
||||
aria-label="Ayuda"
|
||||
>
|
||||
<HelpCircle size={28} />
|
||||
</button>
|
||||
{/snippet}
|
||||
</Sheet.Trigger>
|
||||
<Sheet.Content side="right" class="w-[400px] sm:w-[540px]">
|
||||
<Sheet.Header>
|
||||
<Sheet.Title class="flex items-center gap-2">
|
||||
<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)}>
|
||||
<Button variant="ghost" size="icon" onclick={() => (selectedArticle = null)} class="mr-1 h-8 w-8 rounded-full">
|
||||
<ChevronLeft size={20} />
|
||||
</Button>
|
||||
{/if}
|
||||
<span>Base de Conocimientos</span>
|
||||
</Sheet.Title>
|
||||
</Sheet.Header>
|
||||
<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="mt-6 flex h-[calc(100vh-120px)] flex-col">
|
||||
{#if !selectedArticle}
|
||||
<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">
|
||||
<h3 class="text-lg font-medium">Artículos Disponibles</h3>
|
||||
{#if isAdmin}
|
||||
<Button variant="outline" size="sm" onclick={startCreate}>
|
||||
<Plus size={16} class="mr-2" /> <span>Agregar Nuevo</span>
|
||||
</Button>
|
||||
<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}
|
||||
<p class="py-10 text-center text-sm text-muted-foreground">Cargando...</p>
|
||||
<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}
|
||||
<p class="py-10 text-center text-sm text-muted-foreground">
|
||||
No hay artículos de ayuda disponibles.
|
||||
</p>
|
||||
<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 class="grid gap-2">
|
||||
{#each articles as article (article.uuid)}
|
||||
<button
|
||||
onclick={() => selectArticle(article)}
|
||||
class="flex flex-col items-start rounded-lg border p-4 text-left transition-colors hover:bg-accent"
|
||||
>
|
||||
<span class="font-semibold">{article.title}</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
Última edición: {article.updated_at ? article.updated_at.split('T')[0] : '...'}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-1 flex-col gap-4">
|
||||
</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-4">
|
||||
<input
|
||||
bind:value={editTitle}
|
||||
class="w-full rounded-md border bg-transparent p-2 text-xl font-bold"
|
||||
placeholder="Título del artículo"
|
||||
/>
|
||||
<textarea
|
||||
bind:value={editContent}
|
||||
class="min-h-[400px] w-full flex-1 rounded-md border bg-transparent p-4 font-mono text-sm focus:ring-1 focus:ring-primary focus:outline-none"
|
||||
placeholder="Escribe en Markdown..."
|
||||
></textarea>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
isEditing = false;
|
||||
if (isCreating) selectedArticle = null;
|
||||
isCreating = false;
|
||||
}}
|
||||
>
|
||||
<X size={16} class="mr-2" /> <span>Cancelar</span>
|
||||
</Button>
|
||||
<Button onclick={saveChanges}>
|
||||
<Save size={16} class="mr-2" /> <span>Guardar Cambios</span>
|
||||
</Button>
|
||||
<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="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-2xl font-bold">{selectedArticle.title}</h2>
|
||||
{#if isAdmin}
|
||||
<Button variant="outline" size="sm" onclick={startEdit}>
|
||||
<Edit2 size={16} class="mr-2" /> <span>Editar</span>
|
||||
</Button>
|
||||
{/if}
|
||||
<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 max-w-none border-t pt-4 dark:prose-invert">
|
||||
{#if browser}
|
||||
{@html renderMarkdown(selectedArticle.content)}
|
||||
|
||||
<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}
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
|
||||
<style>
|
||||
/* Estilos adicionales si son necesarios */
|
||||
@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>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { hasAccessTokenInDocument } from '$lib/access-token-cookie-browser';
|
||||
import { shortcutStore, activeShortcutsList } from '$lib/stores/shortcut-store';
|
||||
import { focusStore, interactionMode } from '$lib/stores/focus-store';
|
||||
import { helpStore } from '$lib/stores/help.svelte';
|
||||
import ShortcutsHelpModal from './ShortcutsHelpModal.svelte';
|
||||
|
||||
let showHelp = $state(false);
|
||||
@@ -335,6 +336,16 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// 1.5 HELP DRAWER: F12
|
||||
if (key === 'F12') {
|
||||
if (!authenticated) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
helpStore.toggle();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. ESCAPE
|
||||
if (key === 'Escape') {
|
||||
if (showHelp) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Ship,
|
||||
Truck,
|
||||
Users,
|
||||
Book,
|
||||
} from '@lucide/svelte';
|
||||
import { m } from '$lib/i18n/messages';
|
||||
import { Title } from '../ui/alert';
|
||||
@@ -650,9 +651,9 @@ export function getSidebarData(): SidebarData {
|
||||
icon: Settings2,
|
||||
},
|
||||
{
|
||||
name: m["sidebar.reference_data.ayuda"](),
|
||||
name: m["sidebar.help_center"](),
|
||||
url: "/dashboard/help-center",
|
||||
icon: Frame,
|
||||
icon: Book,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
order: 0
|
||||
});
|
||||
|
||||
// Derived state: Grouped by Category
|
||||
// Derived state: Grouped by Category with prioritization for Manuals
|
||||
const groupedArticles = $derived.by(() => {
|
||||
const filtered = articles
|
||||
.filter(
|
||||
@@ -65,7 +65,14 @@
|
||||
a.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(a.category || 'General').toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
.sort((a, b) => (a.order || 0) - (b.order || 0)); // Sort by order first
|
||||
.sort((a, b) => {
|
||||
// Prioritize Manuales category
|
||||
const catA = (a.category || 'General').toLowerCase();
|
||||
const catB = (b.category || 'General').toLowerCase();
|
||||
if (catA.includes('manual') && !catB.includes('manual')) return -1;
|
||||
if (!catA.includes('manual') && catB.includes('manual')) return 1;
|
||||
return (a.order || 0) - (b.order || 0);
|
||||
});
|
||||
|
||||
const groups: Record<string, HelpArticle[]> = {};
|
||||
filtered.forEach((article) => {
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
FileText,
|
||||
Video as VideoIcon,
|
||||
FileCode,
|
||||
Upload
|
||||
Upload,
|
||||
HelpCircle
|
||||
} from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { marked } from 'marked';
|
||||
@@ -56,6 +57,8 @@
|
||||
let file_url = $state('');
|
||||
let file_size = $state<number | undefined>(undefined);
|
||||
let mime_type = $state('');
|
||||
let context_path = $state('');
|
||||
let tags = $state('');
|
||||
|
||||
// Preview State
|
||||
let showPreview = $state(true);
|
||||
@@ -71,7 +74,11 @@
|
||||
const uuid = $page.params.uuid as string;
|
||||
if (uuid === 'new') {
|
||||
loading = false;
|
||||
// Defaults for new article
|
||||
// Pre-rellenar context_path si viene en la URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.has('context_path')) {
|
||||
context_path = urlParams.get('context_path') || '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -88,6 +95,8 @@
|
||||
file_url = article.file_url || '';
|
||||
file_size = article.file_size ?? undefined;
|
||||
mime_type = article.mime_type || '';
|
||||
context_path = article.context_path || '';
|
||||
tags = article.tags || '';
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error('Error al cargar artículo: ' + e.message);
|
||||
@@ -109,7 +118,9 @@
|
||||
content_type,
|
||||
file_url,
|
||||
file_size,
|
||||
mime_type
|
||||
mime_type,
|
||||
context_path,
|
||||
tags
|
||||
};
|
||||
|
||||
// Auto-generate slug if missing
|
||||
@@ -279,6 +290,11 @@
|
||||
<Label for="title">Título</Label>
|
||||
<Input id="title" bind:value={title} placeholder="Ej: Introducción" />
|
||||
</div>
|
||||
<div class="grid gap-2 border-t pt-4">
|
||||
<Label for="slug">Identificador (Slug)</Label>
|
||||
<Input id="slug" bind:value={slug} placeholder="ej-titulo-articulo" />
|
||||
<p class="text-[10px] text-muted-foreground">Se autogenera del título si se deja vacío.</p>
|
||||
</div>
|
||||
<div class="grid gap-2 border-t pt-4">
|
||||
<Label for="type">Tipo de Contenido</Label>
|
||||
<select
|
||||
@@ -292,14 +308,34 @@
|
||||
<option value="document">Otro Documento</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid gap-2 border-t pt-4">
|
||||
<Label for="category">Categoría</Label>
|
||||
<Input id="category" bind:value={category} placeholder="Ej: General" />
|
||||
</div>
|
||||
<div class="grid gap-2 border-t pt-4">
|
||||
<Label for="order">Orden</Label>
|
||||
<Input id="order" type="number" bind:value={order} />
|
||||
</div>
|
||||
|
||||
<details class="group border-t pt-4">
|
||||
<summary class="flex cursor-pointer items-center justify-between text-xs font-semibold tracking-wider text-muted-foreground uppercase hover:text-primary transition-colors">
|
||||
Opciones Avanzadas
|
||||
<HelpCircle size={14} class="opacity-50 group-open:rotate-180 transition-transform" />
|
||||
</summary>
|
||||
<div class="space-y-4 pt-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="category">Categoría</Label>
|
||||
<Input id="category" bind:value={category} placeholder="Ej: General" />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="order">Orden</Label>
|
||||
<Input id="order" type="number" bind:value={order} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="context_path" class="flex items-center gap-1">
|
||||
Ruta Contextual
|
||||
</Label>
|
||||
<Input id="context_path" bind:value={context_path} placeholder="Ej: /dashboard/..." />
|
||||
<p class="text-[10px] text-muted-foreground">URL donde aparecerá este artículo.</p>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="tags">Etiquetas</Label>
|
||||
<Input id="tags" bind:value={tags} placeholder="Ej: facturas, mermas" />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -47,6 +47,10 @@ export default defineConfig({
|
||||
'/api/uploads': {
|
||||
target: 'http://backend:8000',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/api/v1/core/help-center': {
|
||||
target: 'http://backend:8000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user