Feat: Agregar botones de Copiar Texto y Copiar JSON
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 47s

Implementa funcionalidad de copia en tres secciones del Informe:

📋 Funcionalidades agregadas:
1. Lista de Ingresos
   - Copiar Texto: Formato WhatsApp con emojis y legible
   - Copiar JSON: Formato estructurado para sistemas

2. Top 10 Clientes
   - Copiar Texto: Ranking formateado con métricas
   - Copiar JSON: Array de objetos con datos completos

3. Serie Temporal Acumulada
   - Copiar Texto: Evolución temporal con emojis
   - Copiar JSON: Datos completos para análisis

 Características:
- Botones con iconos (i-lucide-copy y i-lucide-code)
- Disabled cuando no hay datos disponibles
- Alertas de confirmación al copiar
- Formato texto optimizado para WhatsApp
- Incluye metadata: rango de fechas y timestamp

Uso:
- Copiar Texto → Compartir en WhatsApp/Telegram
- Copiar JSON → Integración con otros sistemas
This commit is contained in:
2025-10-30 16:33:54 -06:00
parent 507fb9ba1c
commit 63c7043664
10 changed files with 1143 additions and 134 deletions

View File

@@ -10,8 +10,17 @@
</template>
<script setup lang="ts">
// Inicializar sistema de temas
const { loadTheme, initStorageListener, cleanupStorageListener } = useTheme()
// Signal that the app is ready
onMounted(() => {
// Cargar tema guardado (o aplicar el por defecto)
loadTheme()
// Inicializar sincronización de tema entre pestañas
initStorageListener()
// Add class to HTML element to hide loading screen
if (process.client) {
// Small delay to ensure everything is painted
@@ -20,4 +29,9 @@ onMounted(() => {
}, 100)
}
})
// Limpiar listeners al desmontar
onBeforeUnmount(() => {
cleanupStorageListener()
})
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="rounded-lg border border-[#3a2a16] bg-[#1c140c] px-4 py-3">
<div class="rounded-lg border border-[var(--brand-border)] bg-[var(--brand-surface)] px-4 py-3">
<div class="text-xs text-[var(--brand-text-muted)] uppercase tracking-wide mb-1">{{ label }}</div>
<div class="text-lg font-bold" :class="valueColor">
{{ value }}

View File

@@ -29,12 +29,12 @@ const props = withDefaults(defineProps<Props>(), {
const variantClasses = computed(() => {
const variants = {
default: 'bg-[#1c140c] border-[#3a2a16] text-[var(--brand-text)]',
primary: 'bg-[#1c140c] border-[#c08040] text-[var(--brand-primary)]',
success: 'bg-[#1c140c] border-green-600/30 text-green-400',
danger: 'bg-[#1c140c] border-red-600/30 text-red-400',
warning: 'bg-[#1c140c] border-yellow-600/30 text-yellow-400',
info: 'bg-[#1c140c] border-cyan-600/30 text-cyan-400'
default: 'bg-[var(--brand-surface)] border-[var(--brand-border)] text-[var(--brand-text)]',
primary: 'bg-[var(--brand-surface)] border-[var(--brand-primary-strong)] text-[var(--brand-primary)]',
success: 'bg-[var(--brand-surface)] border-green-600/30 text-green-400',
danger: 'bg-[var(--brand-surface)] border-red-600/30 text-red-400',
warning: 'bg-[var(--brand-surface)] border-yellow-600/30 text-yellow-400',
info: 'bg-[var(--brand-surface)] border-cyan-600/30 text-cyan-400'
}
return variants[props.variant]
})

View File

@@ -69,14 +69,14 @@ const items = computed(() => [
v-model:open="isOpen"
:items="items"
:ui="{
content: 'w-72 p-2 bg-white dark:bg-gray-900 shadow-xl border border-gray-200 dark:border-gray-800 backdrop-blur-xl',
content: 'w-72 p-2 bg-[var(--brand-surface)] shadow-xl border border-[var(--brand-border)] backdrop-blur-xl',
item: {
disabled: 'cursor-default select-text opacity-100',
base: 'group gap-3 rounded-xl transition-all duration-300 hover:bg-gray-50 dark:hover:bg-gray-800/50 active:scale-[0.98]',
base: 'group gap-3 rounded-xl transition-all duration-300 hover:bg-[var(--brand-primary)]/10 active:scale-[0.98]',
padding: 'px-4 py-3'
},
separator: 'my-1.5',
arrow: 'before:bg-white dark:before:bg-gray-900 before:border before:border-gray-200 dark:before:border-gray-800'
arrow: 'before:bg-[var(--brand-surface)] before:border before:border-[var(--brand-border)]'
}"
:popper="{ placement: 'bottom-end', offsetDistance: 12 }"
arrow
@@ -88,43 +88,43 @@ const items = computed(() => [
rounded: 'rounded-full',
padding: { sm: 'p-1' }
}"
class="relative hover:bg-gray-100/80 dark:hover:bg-gray-800/80 transition-all duration-300 hover:scale-110 active:scale-95 group"
class="relative hover:bg-[var(--brand-primary)]/10 transition-all duration-300 hover:scale-110 active:scale-95 group"
>
<div class="relative">
<UAvatar
v-bind="userAvatar"
size="sm"
:ui="{
wrapper: 'ring-2 ring-offset-2 ring-offset-white dark:ring-offset-gray-950 ring-primary-500/30 group-hover:ring-primary-500/60 transition-all duration-300 shadow-lg group-hover:shadow-primary-500/20'
wrapper: 'ring-2 ring-offset-2 ring-offset-[var(--brand-bg)] ring-[var(--brand-primary)]/30 group-hover:ring-[var(--brand-primary)]/60 transition-all duration-300 shadow-lg group-hover:shadow-[var(--brand-primary)]/20'
}"
/>
<span
class="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-green-500 border-2 border-white dark:border-gray-950 rounded-full transition-all duration-300 group-hover:scale-110"
class="absolute -bottom-0.5 -right-0.5 w-3 h-3 bg-green-500 border-2 border-[var(--brand-bg)] rounded-full transition-all duration-300 group-hover:scale-110"
title="En línea"
/>
</div>
</UButton>
<template #account="{ item }">
<div class="flex items-center gap-4 py-2 px-2 border-b border-gray-100 dark:border-gray-800 mb-1">
<div class="flex items-center gap-4 py-2 px-2 border-b border-[var(--brand-border)] mb-1">
<div class="relative">
<UAvatar
v-bind="item.avatar"
size="lg"
:ui="{
wrapper: 'ring-2 ring-primary-500/40 shadow-lg'
wrapper: 'ring-2 ring-[var(--brand-primary)]/40 shadow-lg'
}"
/>
<span
class="absolute -bottom-1 -right-1 w-4 h-4 bg-green-500 border-2 border-white dark:border-gray-900 rounded-full"
class="absolute -bottom-1 -right-1 w-4 h-4 bg-green-500 border-2 border-[var(--brand-surface)] rounded-full"
title="En línea"
/>
</div>
<div class="flex-1 min-w-0">
<p class="font-bold text-base text-gray-900 dark:text-white truncate mb-0.5">
<p class="font-bold text-base text-[var(--brand-text)] truncate mb-0.5">
{{ item.label }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate font-medium">
<p class="text-xs text-[var(--brand-text-muted)] truncate font-medium">
{{ item.description }}
</p>
</div>
@@ -133,18 +133,18 @@ const items = computed(() => [
<template #profile="{ item }">
<div class="flex items-center gap-3">
<div :class="['w-9 h-9 rounded-lg bg-blue-50 dark:bg-blue-950/30 flex items-center justify-center transition-colors duration-200 group-hover:bg-blue-100 dark:group-hover:bg-blue-950/50']">
<div :class="['w-9 h-9 rounded-lg bg-[var(--brand-primary)]/20 flex items-center justify-center transition-colors duration-200 group-hover:bg-[var(--brand-primary)]/30']">
<UIcon :name="item.icon" :class="['w-4 h-4', item.iconClass]" />
</div>
<div class="flex-1 min-w-0">
<p class="font-semibold text-sm text-gray-900 dark:text-white">
<p class="font-semibold text-sm text-[var(--brand-text)]">
{{ item.label }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<p class="text-xs text-[var(--brand-text-muted)]">
{{ item.description }}
</p>
</div>
<UIcon name="i-lucide-chevron-right" class="w-4 h-4 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
<UIcon name="i-lucide-chevron-right" class="w-4 h-4 text-[var(--brand-text-muted)] opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
</div>
</template>
@@ -152,45 +152,45 @@ const items = computed(() => [
<div class="px-2 py-3 space-y-3 max-h-96 overflow-y-auto">
<!-- Username -->
<div class="flex items-start gap-3">
<UIcon name="i-heroicons-at-symbol" class="text-gray-500 dark:text-gray-400 mt-0.5" />
<UIcon name="i-heroicons-at-symbol" class="text-[var(--brand-text-muted)] mt-0.5" />
<div class="flex-1 min-w-0">
<p class="text-xs text-gray-500 dark:text-gray-400">Username</p>
<p class="text-xs text-[var(--brand-text-muted)]">Username</p>
<p class="font-medium text-sm">{{ user?.username }}</p>
</div>
</div>
<!-- Email -->
<div class="flex items-start gap-3">
<UIcon name="i-heroicons-envelope" class="text-gray-500 dark:text-gray-400 mt-0.5" />
<UIcon name="i-heroicons-envelope" class="text-[var(--brand-text-muted)] mt-0.5" />
<div class="flex-1 min-w-0">
<p class="text-xs text-gray-500 dark:text-gray-400">Email</p>
<p class="text-xs text-[var(--brand-text-muted)]">Email</p>
<p class="font-medium text-sm">{{ user?.email || 'No especificado' }}</p>
</div>
</div>
<!-- Nombre completo -->
<div class="flex items-start gap-3">
<UIcon name="i-heroicons-user" class="text-gray-500 dark:text-gray-400 mt-0.5" />
<UIcon name="i-heroicons-user" class="text-[var(--brand-text-muted)] mt-0.5" />
<div class="flex-1 min-w-0">
<p class="text-xs text-gray-500 dark:text-gray-400">Nombre Completo</p>
<p class="text-xs text-[var(--brand-text-muted)]">Nombre Completo</p>
<p class="font-medium text-sm">{{ user?.name || 'No especificado' }}</p>
</div>
</div>
<!-- UID -->
<div class="flex items-start gap-3">
<UIcon name="i-heroicons-key" class="text-gray-500 dark:text-gray-400 mt-0.5" />
<UIcon name="i-heroicons-key" class="text-[var(--brand-text-muted)] mt-0.5" />
<div class="flex-1 min-w-0">
<p class="text-xs text-gray-500 dark:text-gray-400">ID Único</p>
<p class="font-mono text-xs text-gray-600 dark:text-gray-300">{{ user?.uid }}</p>
<p class="text-xs text-[var(--brand-text-muted)]">ID Único</p>
<p class="font-mono text-xs text-[var(--brand-text)]">{{ user?.uid }}</p>
</div>
</div>
<!-- Grupos -->
<div class="flex items-start gap-3">
<UIcon name="i-heroicons-user-group" class="text-gray-500 dark:text-gray-400 mt-0.5" />
<UIcon name="i-heroicons-user-group" class="text-[var(--brand-text-muted)] mt-0.5" />
<div class="flex-1 min-w-0">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">Grupos</p>
<p class="text-xs text-[var(--brand-text-muted)] mb-2">Grupos</p>
<div class="flex flex-wrap gap-2">
<UBadge
v-for="group in user?.groups"
@@ -209,18 +209,18 @@ const items = computed(() => [
</div>
<!-- Metadata de Authentik -->
<div v-if="user?.appSlug || user?.outpostName" class="pt-3 border-t border-gray-200 dark:border-gray-700">
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">Conexión Authentik</p>
<div v-if="user?.appSlug || user?.outpostName" class="pt-3 border-t border-[var(--brand-border)]">
<p class="text-xs text-[var(--brand-text-muted)] mb-2">Conexión Authentik</p>
<div class="space-y-2">
<div v-if="user?.appSlug" class="flex items-center gap-2 text-xs">
<UIcon name="i-heroicons-cube" class="text-gray-400" />
<span class="text-gray-600 dark:text-gray-300">App:</span>
<code class="text-xs bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">{{ user.appSlug }}</code>
<UIcon name="i-heroicons-cube" class="text-[var(--brand-text-muted)]" />
<span class="text-[var(--brand-text)]">App:</span>
<code class="text-xs bg-[var(--brand-bg)]/50 px-1.5 py-0.5 rounded">{{ user.appSlug }}</code>
</div>
<div v-if="user?.outpostName" class="flex items-center gap-2 text-xs">
<UIcon name="i-heroicons-server" class="text-gray-400" />
<span class="text-gray-600 dark:text-gray-300">Outpost:</span>
<code class="text-xs bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">{{ user.outpostName }}</code>
<UIcon name="i-heroicons-server" class="text-[var(--brand-text-muted)]" />
<span class="text-[var(--brand-text)]">Outpost:</span>
<code class="text-xs bg-[var(--brand-bg)]/50 px-1.5 py-0.5 rounded">{{ user.outpostName }}</code>
</div>
</div>
</div>
@@ -229,18 +229,18 @@ const items = computed(() => [
<template #settings="{ item }">
<div class="flex items-center gap-3">
<div :class="['w-9 h-9 rounded-lg bg-gray-50 dark:bg-gray-800/50 flex items-center justify-center transition-colors duration-200 group-hover:bg-gray-100 dark:group-hover:bg-gray-800']">
<div :class="['w-9 h-9 rounded-lg bg-[var(--brand-surface)] flex items-center justify-center transition-colors duration-200 group-hover:bg-[var(--brand-primary)]/30']">
<UIcon :name="item.icon" :class="['w-4 h-4', item.iconClass]" />
</div>
<div class="flex-1 min-w-0">
<p class="font-semibold text-sm text-gray-900 dark:text-white">
{{ item.label }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<p class="text-xs text-[var(--brand-text-muted)]">
{{ item.description }}
</p>
</div>
<UIcon name="i-lucide-chevron-right" class="w-4 h-4 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
<UIcon name="i-lucide-chevron-right" class="w-4 h-4 text-[var(--brand-text-muted)] opacity-0 group-hover:opacity-100 transition-opacity duration-200" />
</div>
</template>

View File

@@ -18,9 +18,9 @@
v-if="!isCollapsed"
src="/logo.png"
alt="Analítica Núcleo"
class="h-8 w-8 rounded-full border border-[#ffe0a0]/40"
class="h-8 w-8 rounded-full border border-[var(--brand-accent)]/40"
/>
<UIcon v-else name="i-lucide-activity" class="size-5 text-[#ffe0a0]" />
<UIcon v-else name="i-lucide-activity" class="size-5 text-[var(--brand-accent)]" />
<span v-if="!isCollapsed" class="text-sm font-semibold text-[var(--brand-text)]">Analítica Núcleo</span>
</div>
</template>

View File

@@ -0,0 +1,239 @@
/**
* Composable para gestionar el sistema de temas de Analítica Núcleo
*
* Proporciona funciones para cargar, aplicar, guardar y resetear temas personalizados.
* El tema se guarda en localStorage y se sincroniza entre pestañas del navegador.
*
* @example
* ```ts
* const { theme, applyTheme, loadTheme, saveTheme, resetTheme } = useTheme()
*
* // Cargar tema guardado
* onMounted(() => loadTheme())
*
* // Modificar tema
* theme.value.primary = '#ff5733'
* applyTheme()
* saveTheme()
* ```
*/
export interface ThemeColors {
bg: string
surface: string
border: string
primary: string
primaryStrong: string
accent: string
text: string
textMuted: string
}
/**
* Tema por defecto - Café
*/
export const defaultTheme: ThemeColors = {
bg: '#14100b',
surface: '#1f180f',
border: '#3a2a16',
primary: '#e0c080',
primaryStrong: '#c08040',
accent: '#ffe0a0',
text: '#fef9f0',
textMuted: '#d8c7a6'
}
/**
* Clave de localStorage donde se guarda el tema
*/
const STORAGE_KEY = 'custom-theme'
/**
* Hook principal del sistema de temas
*/
export const useTheme = () => {
// Estado reactivo compartido entre todos los componentes
const theme = useState<ThemeColors>('app-theme', () => ({ ...defaultTheme }))
/**
* Aplica las variables CSS del tema al DOM
* @param themeColors - Objeto con los colores del tema (opcional, usa el estado actual si no se proporciona)
*/
const applyTheme = (themeColors?: ThemeColors) => {
if (import.meta.client) {
const colors = themeColors || theme.value
const root = document.documentElement
root.style.setProperty('--brand-bg', colors.bg)
root.style.setProperty('--brand-surface', colors.surface)
root.style.setProperty('--brand-border', colors.border)
root.style.setProperty('--brand-primary', colors.primary)
root.style.setProperty('--brand-primary-strong', colors.primaryStrong)
root.style.setProperty('--brand-accent', colors.accent)
root.style.setProperty('--brand-text', colors.text)
root.style.setProperty('--brand-text-muted', colors.textMuted)
}
}
/**
* Carga el tema guardado desde localStorage
* Si no existe tema guardado, aplica el tema por defecto
*/
const loadTheme = () => {
if (import.meta.client) {
try {
const savedTheme = localStorage.getItem(STORAGE_KEY)
if (savedTheme) {
const parsed = JSON.parse(savedTheme) as ThemeColors
theme.value = parsed
applyTheme(parsed)
} else {
// No hay tema guardado, aplicar el por defecto
applyTheme(defaultTheme)
}
} catch (error) {
console.error('Error al cargar el tema desde localStorage:', error)
// En caso de error, aplicar tema por defecto
applyTheme(defaultTheme)
}
}
}
/**
* Guarda el tema actual en localStorage
* @returns true si se guardó correctamente, false si hubo un error
*/
const saveTheme = (): boolean => {
if (import.meta.client) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(theme.value))
return true
} catch (error) {
console.error('Error al guardar el tema en localStorage:', error)
return false
}
}
return false
}
/**
* Resetea el tema a los valores por defecto y limpia localStorage
*/
const resetTheme = () => {
theme.value = { ...defaultTheme }
applyTheme(defaultTheme)
if (import.meta.client) {
try {
localStorage.removeItem(STORAGE_KEY)
} catch (error) {
console.error('Error al limpiar el tema de localStorage:', error)
}
}
}
/**
* Establece un nuevo tema completo
* @param newTheme - Objeto con los colores del nuevo tema
* @param save - Si true, guarda automáticamente en localStorage (default: false)
*/
const setTheme = (newTheme: ThemeColors, save = false) => {
theme.value = { ...newTheme }
applyTheme(newTheme)
if (save) {
saveTheme()
}
}
/**
* Exporta el tema actual como JSON string
* @returns JSON string del tema actual
*/
const exportTheme = (): string => {
return JSON.stringify(theme.value, null, 2)
}
/**
* Importa un tema desde JSON string
* @param jsonString - JSON string con la configuración del tema
* @param save - Si true, guarda automáticamente en localStorage (default: false)
* @returns true si se importó correctamente, false si hubo un error
*/
const importTheme = (jsonString: string, save = false): boolean => {
try {
const parsed = JSON.parse(jsonString) as ThemeColors
// Validar que tenga todas las propiedades requeridas
const requiredKeys: (keyof ThemeColors)[] = [
'bg', 'surface', 'border', 'primary',
'primaryStrong', 'accent', 'text', 'textMuted'
]
for (const key of requiredKeys) {
if (!parsed[key] || typeof parsed[key] !== 'string') {
throw new Error(`Propiedad "${key}" faltante o inválida`)
}
}
setTheme(parsed, save)
return true
} catch (error) {
console.error('Error al importar el tema:', error)
return false
}
}
/**
* Listener para sincronizar cambios de tema entre pestañas
* Se ejecuta automáticamente cuando se detecta un cambio en localStorage
*/
const syncThemeAcrossTabs = (event: StorageEvent) => {
if (event.key === STORAGE_KEY && event.newValue) {
try {
const newTheme = JSON.parse(event.newValue) as ThemeColors
theme.value = newTheme
applyTheme(newTheme)
} catch (error) {
console.error('Error al sincronizar tema entre pestañas:', error)
}
} else if (event.key === STORAGE_KEY && event.newValue === null) {
// El tema fue eliminado en otra pestaña, resetear
resetTheme()
}
}
/**
* Inicializa los listeners de sincronización entre pestañas
* Solo debe llamarse una vez al montar la aplicación
*/
const initStorageListener = () => {
if (import.meta.client) {
window.addEventListener('storage', syncThemeAcrossTabs)
}
}
/**
* Limpia los listeners de sincronización
* Debe llamarse al desmontar si es necesario
*/
const cleanupStorageListener = () => {
if (import.meta.client) {
window.removeEventListener('storage', syncThemeAcrossTabs)
}
}
return {
theme,
defaultTheme,
applyTheme,
loadTheme,
saveTheme,
resetTheme,
setTheme,
exportTheme,
importTheme,
initStorageListener,
cleanupStorageListener
}
}

View File

@@ -7,7 +7,7 @@
<img
src="/logo.png"
alt="Analítica Núcleo"
class="h-16 w-16 rounded-full border border-[#ffe0a0]/40 shadow-lg shadow-[#c08040]/25"
class="h-16 w-16 rounded-full border border-[var(--brand-accent)]/40 shadow-lg shadow-[var(--brand-primary-strong)]/25"
/>
<div class="space-y-1">
<h1 class="text-3xl font-semibold tracking-tight text-[var(--brand-text)]">
@@ -29,8 +29,8 @@
<div class="text-center py-12 space-y-6">
<div class="flex justify-center">
<div class="w-24 h-24 rounded-full bg-gradient-to-br from-[#c08040]/20 to-[#ffe0a0]/20 flex items-center justify-center">
<UIcon name="i-lucide-chart-line" class="size-12 text-[#ffe0a0]" />
<div class="w-24 h-24 rounded-full bg-gradient-to-br from-[var(--brand-primary-strong)]/20 to-[var(--brand-accent)]/20 flex items-center justify-center">
<UIcon name="i-lucide-chart-line" class="size-12 text-[var(--brand-accent)]" />
</div>
</div>
@@ -53,7 +53,7 @@
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-lucide-shield-check" class="size-5 text-[#ffe0a0]" />
<UIcon name="i-lucide-shield-check" class="size-5 text-[var(--brand-accent)]" />
<span class="text-sm font-semibold text-[var(--brand-text)]">Seguridad</span>
</div>
</template>
@@ -65,14 +65,14 @@
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-lucide-user" class="size-5 text-[#ffe0a0]" />
<UIcon name="i-lucide-user" class="size-5 text-[var(--brand-accent)]" />
<span class="text-sm font-semibold text-[var(--brand-text)]">Mi Perfil</span>
</div>
</template>
<p class="text-sm text-[var(--brand-text-muted)] mb-3">
Gestiona tu información personal y preferencias del sistema.
</p>
<NuxtLink to="/profile" class="text-sm font-semibold text-[#ffe0a0] hover:underline">
<NuxtLink to="/profile" class="text-sm font-semibold text-[var(--brand-accent)] hover:underline">
Ver perfil
</NuxtLink>
</UCard>
@@ -80,14 +80,14 @@
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-lucide-settings" class="size-5 text-[#ffe0a0]" />
<UIcon name="i-lucide-settings" class="size-5 text-[var(--brand-accent)]" />
<span class="text-sm font-semibold text-[var(--brand-text)]">Configuración</span>
</div>
</template>
<p class="text-sm text-[var(--brand-text-muted)] mb-3">
Personaliza tu experiencia y ajusta las preferencias del panel.
</p>
<NuxtLink to="/settings" class="text-sm font-semibold text-[#ffe0a0] hover:underline">
<NuxtLink to="/settings" class="text-sm font-semibold text-[var(--brand-accent)] hover:underline">
Ir a configuración
</NuxtLink>
</UCard>

View File

@@ -370,12 +370,36 @@
<!-- Lista de Ingresos -->
<UCard v-if="pageSections.tablaIngresos" class="brand-card border border-transparent">
<template #header>
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-bold brand-section-title">Lista de Ingresos</h2>
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
Detalles completos de {{ data.listaIngresos?.length || 0 }} ingresos filtrados
</p>
</div>
<div class="flex items-center gap-2">
<UButton
size="xs"
color="neutral"
variant="outline"
icon="i-lucide-copy"
@click="copiarListaIngresosTexto"
:disabled="!data.listaIngresos || data.listaIngresos.length === 0"
>
Copiar Texto
</UButton>
<UButton
size="xs"
color="neutral"
variant="outline"
icon="i-lucide-code"
@click="copiarListaIngresosJSON"
:disabled="!data.listaIngresos || data.listaIngresos.length === 0"
>
Copiar JSON
</UButton>
</div>
</div>
</template>
<div v-if="data.listaIngresos && data.listaIngresos.length > 0" class="overflow-x-auto max-h-[600px] overflow-y-auto">
@@ -447,12 +471,36 @@
<!-- Top 10 Clientes -->
<UCard v-if="pageSections.top10Clientes" class="brand-card border border-transparent">
<template #header>
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-bold brand-section-title">Top 10 Clientes</h2>
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
Clientes ordenados por monto total de ingresos
</p>
</div>
<div class="flex items-center gap-2">
<UButton
size="xs"
color="neutral"
variant="outline"
icon="i-lucide-copy"
@click="copiarTop10ClientesTexto"
:disabled="!data.listaClientes || data.listaClientes.length === 0"
>
Copiar Texto
</UButton>
<UButton
size="xs"
color="neutral"
variant="outline"
icon="i-lucide-code"
@click="copiarTop10ClientesJSON"
:disabled="!data.listaClientes || data.listaClientes.length === 0"
>
Copiar JSON
</UButton>
</div>
</div>
</template>
<div v-if="data.listaClientes && data.listaClientes.length > 0" class="space-y-3">
@@ -491,12 +539,36 @@
<div v-if="pageSections.graficas && data.serieTemporal && data.serieTemporal.length > 0" class="space-y-6">
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex items-center justify-between">
<div>
<h2 class="text-xl font-bold brand-section-title">Serie Temporal Acumulada</h2>
<p class="text-xs text-[var(--brand-text-muted)] mt-1">
Evolución de ingresos en el tiempo ({{ data.serieTemporal.length }} puntos de datos)
</p>
</div>
<div class="flex items-center gap-2">
<UButton
size="xs"
color="neutral"
variant="outline"
icon="i-lucide-copy"
@click="copiarSerieTemporalTexto"
:disabled="!data.serieTemporal || data.serieTemporal.length === 0"
>
Copiar Texto
</UButton>
<UButton
size="xs"
color="neutral"
variant="outline"
icon="i-lucide-code"
@click="copiarSerieTemporalJSON"
:disabled="!data.serieTemporal || data.serieTemporal.length === 0"
>
Copiar JSON
</UButton>
</div>
</div>
</template>
<div class="space-y-4">
@@ -773,6 +845,97 @@ async function loadOpcionesFiltros() {
}
}
// Funciones de copia
async function copiarListaIngresosTexto() {
if (!data.value?.listaIngresos || data.value.listaIngresos.length === 0) return
const texto = `📊 LISTA DE INGRESOS - ${data.value.listaIngresos.length} registros
Rango: ${rangoLegible.value}
Generado: ${lastUpdated.value}
${data.value.listaIngresos.map((ing, idx) => `
${idx + 1}. ID: ${ing.id}
📅 Fecha: ${ing.created_at ? new Date(ing.created_at).toLocaleDateString('es-ES') : '-'}
👤 Cliente: ${ing.cliente_nombre || '-'}
☕ Tipo: ${ing.tipo || '-'}
⚖️ Peso: ${ing.peso_neto ? ing.peso_neto.toFixed(2) : '-'} lb
💰 Precio: L ${ing.precio ? ing.precio.toFixed(2) : '-'}
💵 Total: L ${ing.total_a_pagar ? ing.total_a_pagar.toFixed(2) : '-'}
📍 Estado: ${ing.estado || '-'}
`).join('\n')}`
await navigator.clipboard.writeText(texto)
alert('✅ Lista de ingresos copiada al portapapeles')
}
async function copiarListaIngresosJSON() {
if (!data.value?.listaIngresos || data.value.listaIngresos.length === 0) return
const json = JSON.stringify(data.value.listaIngresos, null, 2)
await navigator.clipboard.writeText(json)
alert('✅ JSON copiado al portapapeles')
}
async function copiarTop10ClientesTexto() {
if (!data.value?.listaClientes || data.value.listaClientes.length === 0) return
const top10 = data.value.listaClientes.slice(0, 10)
const texto = `🏆 TOP 10 CLIENTES
Rango: ${rangoLegible.value}
Generado: ${lastUpdated.value}
${top10.map((cliente, idx) => `
${idx + 1}. ${cliente.cliente_nombre || 'Sin nombre'}
📄 Cédula: ${cliente.cliente_cedula || 'Sin cédula'}
📍 Ubicación: ${cliente.cliente_ubicacion || 'Sin ubicación'}
💰 Total Pagado: L ${cliente.total_pagado ? cliente.total_pagado.toFixed(2) : '0.00'}
📦 Ingresos: ${cliente.num_ingresos || 0}
⚖️ Quintales: ${cliente.total_qq_seco ? cliente.total_qq_seco.toFixed(2) : '0.00'} qq
`).join('\n')}`
await navigator.clipboard.writeText(texto)
alert('✅ Top 10 clientes copiado al portapapeles')
}
async function copiarTop10ClientesJSON() {
if (!data.value?.listaClientes || data.value.listaClientes.length === 0) return
const top10 = data.value.listaClientes.slice(0, 10)
const json = JSON.stringify(top10, null, 2)
await navigator.clipboard.writeText(json)
alert('✅ JSON copiado al portapapeles')
}
async function copiarSerieTemporalTexto() {
if (!data.value?.serieTemporal || data.value.serieTemporal.length === 0) return
const texto = `📈 SERIE TEMPORAL ACUMULADA - ${data.value.serieTemporal.length} puntos
Rango: ${rangoLegible.value}
Generado: ${lastUpdated.value}
${data.value.serieTemporal.map((punto, idx) => `
${idx + 1}. 📅 ${punto.fecha_grupo ? new Date(punto.fecha_grupo).toLocaleDateString('es-ES') : '-'}
☕ Tipo: ${punto.tipo || '-'}
📍 Estado: ${punto.estado || '-'}
📦 Ingresos: ${punto.num_ingresos_periodo || 0}
⚖️ Peso Seco: ${punto.peso_seco_periodo ? punto.peso_seco_periodo.toFixed(2) : '0.00'} qq
💰 Inversión: L ${punto.inversion_periodo ? punto.inversion_periodo.toFixed(2) : '0.00'}
📊 Acumulado: ${punto.peso_seco_acumulado ? punto.peso_seco_acumulado.toFixed(2) : '0.00'} qq
💵 Total Acum: L ${punto.inversion_acumulada ? punto.inversion_acumulada.toFixed(2) : '0.00'}
`).join('\n')}`
await navigator.clipboard.writeText(texto)
alert('✅ Serie temporal copiada al portapapeles')
}
async function copiarSerieTemporalJSON() {
if (!data.value?.serieTemporal || data.value.serieTemporal.length === 0) return
const json = JSON.stringify(data.value.serieTemporal, null, 2)
await navigator.clipboard.writeText(json)
alert('✅ JSON copiado al portapapeles')
}
// Inicializar preset por defecto sin cargar datos
onMounted(async () => {
// Default preset: cosecha 25-26

View File

@@ -1,68 +1,91 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { ThemeColors } from '~/composables/useTheme'
definePageMeta({
layout: 'dashboard',
title: 'Configuración'
})
// Colores por defecto (actuales del tema café)
const defaultTheme = {
bg: '#14100b',
surface: '#1f180f',
border: '#3a2a16',
primary: '#e0c080',
primaryStrong: '#c08040',
accent: '#ffe0a0',
text: '#fef9f0',
textMuted: '#d8c7a6'
// Usar el composable de temas
const {
theme,
defaultTheme,
applyTheme,
saveTheme: saveThemeComposable,
resetTheme: resetThemeComposable,
exportTheme,
importTheme
} = useTheme()
// Temas predefinidos
const presetThemes: Record<string, ThemeColors> = {
cafe: { ...defaultTheme },
azul: {
bg: '#0a0e1a',
surface: '#151a28',
border: '#2d3748',
primary: '#60a5fa',
primaryStrong: '#3b82f6',
accent: '#93c5fd',
text: '#f0f4f8',
textMuted: '#cbd5e1'
},
verde: {
bg: '#0f1a14',
surface: '#1a2820',
border: '#2d4033',
primary: '#86efac',
primaryStrong: '#4ade80',
accent: '#bbf7d0',
text: '#f0fdf4',
textMuted: '#d1fae5'
},
carbon: {
bg: '#0f0f0f',
surface: '#1a1a1a',
border: '#333333',
primary: '#a3a3a3',
primaryStrong: '#737373',
accent: '#d4d4d4',
text: '#fafafa',
textMuted: '#d4d4d4'
}
}
// Estado del tema (inicializar con valores actuales del CSS)
const theme = ref({ ...defaultTheme })
// Cargar tema guardado en localStorage
onMounted(() => {
const savedTheme = localStorage.getItem('custom-theme')
if (savedTheme) {
try {
theme.value = JSON.parse(savedTheme)
// Aplicar preset de tema
const applyPreset = (presetName: keyof typeof presetThemes) => {
const preset = presetThemes[presetName]
theme.value = { ...preset }
applyTheme(theme.value)
} catch (e) {
console.error('Error al cargar tema guardado', e)
}
}
})
// Aplicar tema al documento
const applyTheme = (themeColors: typeof defaultTheme) => {
const root = document.documentElement
root.style.setProperty('--brand-bg', themeColors.bg)
root.style.setProperty('--brand-surface', themeColors.surface)
root.style.setProperty('--brand-border', themeColors.border)
root.style.setProperty('--brand-primary', themeColors.primary)
root.style.setProperty('--brand-primary-strong', themeColors.primaryStrong)
root.style.setProperty('--brand-accent', themeColors.accent)
root.style.setProperty('--brand-text', themeColors.text)
root.style.setProperty('--brand-text-muted', themeColors.textMuted)
useToast().add({
title: 'Tema aplicado',
description: `Se aplicó el tema ${presetName}`,
color: 'blue'
})
}
// Guardar tema
const saveTheme = () => {
localStorage.setItem('custom-theme', JSON.stringify(theme.value))
applyTheme(theme.value)
// Guardar tema con feedback
const handleSaveTheme = () => {
const success = saveThemeComposable()
if (success) {
useToast().add({
title: 'Tema guardado',
description: 'Los cambios se aplicaron correctamente',
color: 'green'
})
} else {
useToast().add({
title: 'Error al guardar',
description: 'No se pudo guardar el tema',
color: 'red'
})
}
}
// Resetear al tema por defecto
const resetTheme = () => {
theme.value = { ...defaultTheme }
localStorage.removeItem('custom-theme')
applyTheme(theme.value)
const handleResetTheme = () => {
resetThemeComposable()
useToast().add({
title: 'Tema reseteado',
description: 'Se restauraron los colores por defecto',
@@ -70,8 +93,50 @@ const resetTheme = () => {
})
}
// Vista previa en tiempo real (opcional)
const livePreview = ref(false)
// Exportar tema al portapapeles
const handleExportTheme = async () => {
try {
const themeJson = exportTheme()
await navigator.clipboard.writeText(themeJson)
useToast().add({
title: 'Tema exportado',
description: 'El JSON del tema se copió al portapapeles',
color: 'green'
})
} catch (error) {
useToast().add({
title: 'Error al exportar',
description: 'No se pudo copiar al portapapeles',
color: 'red'
})
}
}
// Importar tema desde JSON
const importJsonInput = ref('')
const showImportModal = ref(false)
const handleImportTheme = () => {
const success = importTheme(importJsonInput.value, false)
if (success) {
useToast().add({
title: 'Tema importado',
description: 'El tema se aplicó correctamente',
color: 'green'
})
showImportModal.value = false
importJsonInput.value = ''
} else {
useToast().add({
title: 'Error al importar',
description: 'El JSON proporcionado no es válido',
color: 'red'
})
}
}
// Vista previa en tiempo real (activada por defecto)
const livePreview = ref(true)
watch(() => theme.value, (newTheme) => {
if (livePreview.value) {
applyTheme(newTheme)
@@ -110,6 +175,58 @@ watch(() => theme.value, (newTheme) => {
</div>
</template>
<!-- Temas Predefinidos -->
<div class="mb-6 pb-6 border-b border-[var(--brand-border)]">
<label class="block text-sm font-medium text-[var(--brand-text)] mb-3">
Temas Predefinidos
</label>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<button
@click="applyPreset('cafe')"
class="p-4 rounded-lg border-2 border-[var(--brand-border)] hover:border-[var(--brand-primary)] transition-colors group"
>
<div class="flex gap-2 mb-2">
<div class="w-6 h-6 rounded" style="background: #e0c080" />
<div class="w-6 h-6 rounded" style="background: #1f180f" />
</div>
<p class="text-xs text-[var(--brand-text)] font-medium group-hover:text-[var(--brand-primary)]">Café</p>
</button>
<button
@click="applyPreset('azul')"
class="p-4 rounded-lg border-2 border-[var(--brand-border)] hover:border-[var(--brand-primary)] transition-colors group"
>
<div class="flex gap-2 mb-2">
<div class="w-6 h-6 rounded" style="background: #60a5fa" />
<div class="w-6 h-6 rounded" style="background: #151a28" />
</div>
<p class="text-xs text-[var(--brand-text)] font-medium group-hover:text-[var(--brand-primary)]">Azul</p>
</button>
<button
@click="applyPreset('verde')"
class="p-4 rounded-lg border-2 border-[var(--brand-border)] hover:border-[var(--brand-primary)] transition-colors group"
>
<div class="flex gap-2 mb-2">
<div class="w-6 h-6 rounded" style="background: #86efac" />
<div class="w-6 h-6 rounded" style="background: #1a2820" />
</div>
<p class="text-xs text-[var(--brand-text)] font-medium group-hover:text-[var(--brand-primary)]">Verde</p>
</button>
<button
@click="applyPreset('carbon')"
class="p-4 rounded-lg border-2 border-[var(--brand-border)] hover:border-[var(--brand-primary)] transition-colors group"
>
<div class="flex gap-2 mb-2">
<div class="w-6 h-6 rounded" style="background: #a3a3a3" />
<div class="w-6 h-6 rounded" style="background: #1a1a1a" />
</div>
<p class="text-xs text-[var(--brand-text)] font-medium group-hover:text-[var(--brand-primary)]">Carbón</p>
</button>
</div>
</div>
<div class="space-y-6">
<!-- Fondo Principal -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -268,26 +385,83 @@ watch(() => theme.value, (newTheme) => {
</div>
<!-- Acciones -->
<div class="flex justify-between items-center pt-4 border-t border-[var(--brand-border)]">
<div class="space-y-3 pt-4 border-t border-[var(--brand-border)]">
<!-- Botones principales -->
<div class="flex justify-between items-center">
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-rotate-ccw"
@click="resetTheme"
@click="handleResetTheme"
>
Restaurar por defecto
</UButton>
<UButton
color="primary"
icon="i-lucide-save"
@click="saveTheme"
@click="handleSaveTheme"
>
Guardar cambios
</UButton>
</div>
<!-- Botones de import/export -->
<div class="flex gap-2">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-download"
@click="handleExportTheme"
class="flex-1"
>
Exportar tema
</UButton>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-upload"
@click="showImportModal = true"
class="flex-1"
>
Importar tema
</UButton>
</div>
</div>
</div>
</UCard>
<!-- Modal de Importar Tema -->
<UModal v-model="showImportModal" title="Importar Tema">
<div class="p-4 space-y-4">
<div>
<label class="block text-sm font-medium text-[var(--brand-text)] mb-2">
Pega el JSON del tema
</label>
<UTextarea
v-model="importJsonInput"
:rows="10"
placeholder='{\n "bg": "#14100b",\n "surface": "#1f180f",\n ...\n}'
class="font-mono text-xs"
/>
</div>
<div class="flex justify-end gap-2">
<UButton
color="neutral"
variant="ghost"
@click="showImportModal = false"
>
Cancelar
</UButton>
<UButton
color="primary"
@click="handleImportTheme"
>
Importar
</UButton>
</div>
</div>
</UModal>
<!-- Vista previa de colores -->
<UCard>
<template #header>

View File

@@ -0,0 +1,419 @@
# Guía de Desarrollo - Sistema de Temas
## Introducción
Esta guía está dirigida a desarrolladores que trabajan en Analítica Núcleo y necesitan entender cómo funciona el sistema de temas y cómo mantener la consistencia visual en toda la aplicación.
## Principios Fundamentales
### 1. Siempre Usa Variables CSS
**✅ CORRECTO:**
```vue
<div class="bg-[var(--brand-surface)] border-[var(--brand-border)] text-[var(--brand-text)]">
Contenido
</div>
```
**❌ INCORRECTO:**
```vue
<div class="bg-[#1f180f] border-[#3a2a16] text-[#fef9f0]">
Contenido
</div>
```
**❌ INCORRECTO:**
```vue
<div class="bg-gray-900 border-gray-800 text-white">
Contenido
</div>
```
### 2. Usa las Clases Utilitarias de Marca
Para elementos comunes, usa las clases predefinidas en lugar de duplicar estilos:
```vue
<!-- CORRECTO -->
<div class="brand-card">
<h2 class="brand-section-title">Título</h2>
<div class="brand-divider"></div>
</div>
<!-- INCORRECTO -->
<div class="rounded-lg bg-gradient-to-br from-[#1f180f] to-[#14100b] border border-[#3a2a16]">
<h2 class="text-lg font-semibold text-[#e0c080]">Título</h2>
<div class="h-px bg-[#3a2a16] my-4"></div>
</div>
```
## Variables de Tema Disponibles
| Variable CSS | Uso | Ejemplo |
|--------------|-----|---------|
| `--brand-bg` | Fondo principal de la aplicación | `bg-[var(--brand-bg)]` |
| `--brand-surface` | Fondos de elementos elevados (cards, modales) | `bg-[var(--brand-surface)]` |
| `--brand-border` | Bordes de elementos | `border-[var(--brand-border)]` |
| `--brand-primary` | Color principal (enlaces, botones) | `text-[var(--brand-primary)]` |
| `--brand-primary-strong` | Variante más intensa del primario | `bg-[var(--brand-primary-strong)]` |
| `--brand-accent` | Color de acento para highlights | `text-[var(--brand-accent)]` |
| `--brand-text` | Texto principal | `text-[var(--brand-text)]` |
| `--brand-text-muted` | Texto secundario/menos prominente | `text-[var(--brand-text-muted)]` |
## Clases Utilitarias de Marca
### `.brand-shell`
Contenedor principal para layouts con gradiente radial.
```vue
<div class="brand-shell">
<!-- Contenido de la página -->
</div>
```
### `.brand-card`
Tarjetas con estilos consistentes.
```vue
<div class="brand-card">
<h3>Título de la Card</h3>
<p>Contenido</p>
</div>
```
### `.brand-divider`
Divisor horizontal decorativo.
```vue
<div class="brand-divider"></div>
```
### `.brand-chip` y `.brand-pill`
Para pequeños tags o badges.
```vue
<span class="brand-chip">Estado</span>
<span class="brand-pill">Badge</span>
```
### `.brand-table`
Estilos para tablas.
```vue
<table class="brand-table">
<!-- ... -->
</table>
```
## Patrones de Uso Común
### Cards con Bordes y Sombras
```vue
<UCard class="brand-card border border-transparent">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-lucide-star" class="size-5 text-[var(--brand-accent)]" />
<h3 class="text-[var(--brand-text)]">Título</h3>
</div>
</template>
<p class="text-[var(--brand-text-muted)]">
Descripción del contenido
</p>
</UCard>
```
### Botones con Hover Personalizado
```vue
<button class="px-4 py-2 rounded-lg bg-[var(--brand-surface)] border border-[var(--brand-border)] text-[var(--brand-text)] hover:bg-[var(--brand-primary)]/10 hover:border-[var(--brand-primary)] transition-colors">
Botón
</button>
```
### Inputs y Formularios
```vue
<UInput
v-model="value"
placeholder="Ingresa un valor"
:ui="{
base: 'bg-[var(--brand-surface)] border-[var(--brand-border)] text-[var(--brand-text)]',
placeholder: 'placeholder:text-[var(--brand-text-muted)]'
}"
/>
```
### Links con Hover
```vue
<NuxtLink
to="/perfil"
class="text-[var(--brand-accent)] hover:text-[var(--brand-primary)] hover:underline transition-colors"
>
Ver perfil
</NuxtLink>
```
### Personalización de Componentes Nuxt UI
Muchos componentes de Nuxt UI aceptan la prop `:ui` para personalización:
```vue
<UButton
:ui="{
base: 'bg-[var(--brand-surface)] hover:bg-[var(--brand-primary)]/10',
padding: { sm: 'px-2.5 py-2' }
}"
>
Botón Personalizado
</UButton>
```
## Composable `useTheme()`
Para manipular el tema programáticamente, usa el composable `useTheme()`:
```vue
<script setup lang="ts">
const { theme, applyTheme, saveTheme, resetTheme, exportTheme, importTheme } = useTheme()
// Modificar un color
theme.value.primary = '#ff5733'
// Aplicar cambios
applyTheme()
// Guardar en localStorage
saveTheme()
// Resetear a valores por defecto
resetTheme()
// Exportar tema como JSON
const jsonTheme = exportTheme()
// Importar tema desde JSON
const success = importTheme(jsonString, true) // true = guardar automáticamente
</script>
```
## Checklist Pre-Commit
Antes de hacer commit de tu código, verifica:
- [ ] **No hay colores hardcoded** (`#ffffff`, `#000000`, etc.)
- [ ] **No hay clases gray-scale genéricas** (`bg-gray-900`, `text-gray-500`, etc.)
- [ ] **Todos los fondos usan** `--brand-bg` o `--brand-surface`
- [ ] **Todos los bordes usan** `--brand-border`
- [ ] **Todo el texto usa** `--brand-text` o `--brand-text-muted`
- [ ] **Los elementos interactivos usan** `--brand-primary` o `--brand-accent`
- [ ] **Los hover states usan transparencias** (ej: `hover:bg-[var(--brand-primary)]/10`)
- [ ] **Se usan clases utilitarias** cuando sea posible (`.brand-card`, `.brand-shell`, etc.)
## Casos Especiales
### Colores Semánticos (Success, Error, Warning)
Para colores que NO son parte del tema (éxito, error, advertencia), puedes usar colores de Tailwind:
```vue
<!-- Estados semánticos - OK usar colores fijos -->
<div class="text-green-400">Éxito</div>
<div class="text-red-400">Error</div>
<div class="text-yellow-400">Advertencia</div>
<div class="text-blue-400">Info</div>
<!-- Pero el fondo y borde deben respetar el tema -->
<div class="bg-[var(--brand-surface)] border-green-600/30 text-green-400">
Operación exitosa
</div>
```
### Iconos
Los iconos deben usar colores del tema:
```vue
<!-- CORRECTO -->
<UIcon name="i-lucide-star" class="size-5 text-[var(--brand-primary)]" />
<UIcon name="i-lucide-info" class="size-4 text-[var(--brand-accent)]" />
<UIcon name="i-lucide-chevron-right" class="size-4 text-[var(--brand-text-muted)]" />
<!-- INCORRECTO -->
<UIcon name="i-lucide-star" class="size-5 text-amber-500" />
<UIcon name="i-lucide-info" class="size-4 text-blue-400" />
```
### Imágenes y Avatares con Bordes
```vue
<img
src="/logo.png"
class="rounded-full border-2 border-[var(--brand-accent)]/40 shadow-lg shadow-[var(--brand-primary-strong)]/25"
/>
<UAvatar
src="https://example.com/avatar.jpg"
:ui="{
wrapper: 'ring-2 ring-[var(--brand-primary)]/40'
}"
/>
```
### Hover y Focus States
Siempre usa transparencias para hover states para que funcionen con cualquier tema:
```vue
<!-- CORRECTO - Usa transparencia -->
<button class="hover:bg-[var(--brand-primary)]/10 focus:ring-2 focus:ring-[var(--brand-primary)]/50">
Botón
</button>
<!-- INCORRECTO - Color sólido -->
<button class="hover:bg-[#e0c080] focus:ring-2 focus:ring-[#c08040]">
Botón
</button>
```
## Ejemplos de Migración
### Antes (Incorrecto)
```vue
<template>
<div class="bg-gray-900 border border-gray-800">
<h2 class="text-white font-bold">Título</h2>
<p class="text-gray-400 mt-2">
Descripción
</p>
<button class="mt-4 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">
Acción
</button>
</div>
</template>
```
### Después (Correcto)
```vue
<template>
<div class="bg-[var(--brand-surface)] border border-[var(--brand-border)]">
<h2 class="text-[var(--brand-text)] font-bold">Título</h2>
<p class="text-[var(--brand-text-muted)] mt-2">
Descripción
</p>
<UButton
class="mt-4"
:ui="{
base: 'bg-[var(--brand-primary)] hover:bg-[var(--brand-primary-strong)]'
}"
>
Acción
</UButton>
</div>
</template>
```
## Errores Comunes y Soluciones
### Error 1: Mezclar Variables con Hardcoded Colors
**❌ No hagas esto:**
```vue
<div class="bg-[#1f180f] text-[var(--brand-text)]">
Contenido
</div>
```
**✅ Haz esto:**
```vue
<div class="bg-[var(--brand-surface)] text-[var(--brand-text)]">
Contenido
</div>
```
### Error 2: No Considerar el Contraste
Asegúrate de que el texto tenga suficiente contraste sobre su fondo:
**❌ Mal contraste:**
```vue
<div class="bg-[var(--brand-surface)]">
<span class="text-[var(--brand-text-muted)]">Texto importante</span>
</div>
```
**✅ Buen contraste:**
```vue
<div class="bg-[var(--brand-surface)]">
<span class="text-[var(--brand-text)]">Texto importante</span>
</div>
```
### Error 3: Duplicar Estilos en Lugar de Usar Clases
**❌ Duplicación:**
```vue
<div class="rounded-lg bg-gradient-to-br from-[var(--brand-surface)] to-[var(--brand-bg)] border border-[var(--brand-border)] p-6 shadow-xl">
Card 1
</div>
<div class="rounded-lg bg-gradient-to-br from-[var(--brand-surface)] to-[var(--brand-bg)] border border-[var(--brand-border)] p-6 shadow-xl">
Card 2
</div>
```
**✅ Reutilización:**
```vue
<div class="brand-card">Card 1</div>
<div class="brand-card">Card 2</div>
```
## Testing del Tema
Para probar que tu componente respeta el tema:
1. Ve a `/settings`
2. Cambia el tema a "Azul Corporativo" o "Verde Natural"
3. Navega a tu componente
4. Verifica que todos los colores cambien correctamente
5. Verifica que no haya elementos con colores hardcoded que no cambiaron
## Recursos Adicionales
- **Documentación del usuario:** `THEME_CUSTOMIZATION.md`
- **Composable de temas:** `app/composables/useTheme.ts`
- **Variables CSS:** `app/assets/css/main.css`
- **Configuración de Nuxt UI:** `app.config.ts`
## Preguntas Frecuentes
### ¿Puedo usar colores de Tailwind como `bg-blue-500`?
Solo para estados semánticos (éxito, error, advertencia) que NO forman parte del tema. Para todo lo demás, usa las variables de tema.
### ¿Cómo personalizo un componente de Nuxt UI?
Usa la prop `:ui` con las variables de tema:
```vue
<UCard :ui="{ base: 'bg-[var(--brand-surface)]' }">
Contenido
</UCard>
```
### ¿Qué hago si necesito un color que no está en el tema?
Primero pregunta: ¿realmente necesito un color nuevo, o puedo usar uno existente? Si es absolutamente necesario, propón agregar una nueva variable al sistema de temas en lugar de hardcodear el color.
### ¿Cómo manejo dark mode?
No necesitas manejarlo manualmente. Las variables CSS ya están optimizadas para dark mode. Solo usa las variables y funcionará automáticamente.
---
**Última actualización:** 2025-10-30
**Mantenedor:** Claude Code para Núcleo Río Frío
**Versión:** 1.0.0