Feat: Agregar botones de Copiar Texto y Copiar JSON
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 47s
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:
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
239
nuxt4-app/app/composables/useTheme.ts
Normal file
239
nuxt4-app/app/composables/useTheme.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
419
nuxt4-app/docs/DEVELOPER_GUIDE.md
Normal file
419
nuxt4-app/docs/DEVELOPER_GUIDE.md
Normal 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
|
||||
Reference in New Issue
Block a user