Feat: Implementar sistema de notificaciones con historial por usuario
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 48s
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 48s
- Crear composable useNotifications con gestión por localStorage - Almacenamiento separado por usuario usando UID de Authentik - Auto-limpieza de notificaciones mayores a 30 días - Sincronización automática entre pestañas - Filtrado por tipo, búsqueda y gestión completa - Crear wrapper useToast para guardar toasts automáticamente - Intercepta todos los toasts de la aplicación - Guarda historial sin afectar funcionalidad existente - Implementar endpoints de API para notificaciones del backend - POST /api/notifications/send para enviar notificaciones - GET /api/notifications/list para obtener pendientes - Actualizar página de notificaciones con funcionalidad real - Búsqueda y filtros por tipo (info, warning, success, error) - Eliminar individual o todas las notificaciones - Marcar como leídas individual o todas - Badges de origen (toast, backend, manual) - Estados vacíos con mensajes informativos - Actualizar badge del sidebar con contador dinámico - Muestra número real de notificaciones no leídas - Se oculta cuando no hay notificaciones - Inicializar sistema en app.vue - Auto-inicialización al montar la app - Limpieza automática de notificaciones antiguas
This commit is contained in:
@@ -13,6 +13,9 @@
|
|||||||
// Inicializar sistema de temas
|
// Inicializar sistema de temas
|
||||||
const { loadTheme, initStorageListener, cleanupStorageListener } = useTheme()
|
const { loadTheme, initStorageListener, cleanupStorageListener } = useTheme()
|
||||||
|
|
||||||
|
// Inicializar sistema de notificaciones
|
||||||
|
const { initialize: initNotifications, cleanupOldNotifications } = useNotifications()
|
||||||
|
|
||||||
// Signal that the app is ready
|
// Signal that the app is ready
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Cargar tema guardado (o aplicar el por defecto)
|
// Cargar tema guardado (o aplicar el por defecto)
|
||||||
@@ -21,6 +24,12 @@ onMounted(() => {
|
|||||||
// Inicializar sincronización de tema entre pestañas
|
// Inicializar sincronización de tema entre pestañas
|
||||||
initStorageListener()
|
initStorageListener()
|
||||||
|
|
||||||
|
// Inicializar sistema de notificaciones
|
||||||
|
initNotifications()
|
||||||
|
|
||||||
|
// Limpiar notificaciones antiguas (> 30 días)
|
||||||
|
cleanupOldNotifications()
|
||||||
|
|
||||||
// Add class to HTML element to hide loading screen
|
// Add class to HTML element to hide loading screen
|
||||||
if (process.client) {
|
if (process.client) {
|
||||||
// Small delay to ensure everything is painted
|
// Small delay to ensure everything is painted
|
||||||
|
|||||||
@@ -156,12 +156,12 @@
|
|||||||
<template #leading>
|
<template #leading>
|
||||||
<div class="w-7 h-7 rounded-md bg-[var(--brand-surface)] flex items-center justify-center relative flex-shrink-0">
|
<div class="w-7 h-7 rounded-md bg-[var(--brand-surface)] flex items-center justify-center relative flex-shrink-0">
|
||||||
<UIcon name="i-lucide-bell" class="size-3.5 text-[var(--brand-primary)]" />
|
<UIcon name="i-lucide-bell" class="size-3.5 text-[var(--brand-primary)]" />
|
||||||
<span class="absolute -top-0.5 -right-0.5 w-2 h-2 bg-[var(--brand-accent)] rounded-full ring-1 ring-[var(--brand-surface)]" />
|
<span v-if="unreadCount > 0" class="absolute -top-0.5 -right-0.5 w-2 h-2 bg-[var(--brand-accent)] rounded-full ring-1 ring-[var(--brand-surface)]" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<span class="text-sm font-medium text-[var(--brand-text)]">Notificaciones</span>
|
<span class="text-sm font-medium text-[var(--brand-text)]">Notificaciones</span>
|
||||||
<template #trailing>
|
<template v-if="unreadCount > 0" #trailing>
|
||||||
<UBadge color="neutral" variant="solid" size="xs" class="ml-auto bg-[var(--brand-accent)] text-[var(--brand-bg)]">3</UBadge>
|
<UBadge color="neutral" variant="solid" size="xs" class="ml-auto bg-[var(--brand-accent)] text-[var(--brand-bg)]">{{ unreadCount }}</UBadge>
|
||||||
</template>
|
</template>
|
||||||
</UButton>
|
</UButton>
|
||||||
|
|
||||||
@@ -319,6 +319,9 @@ const navigationPrimary = computed<NavigationMenuItem[]>(() => [
|
|||||||
|
|
||||||
const { user, isAuthenticated, logout } = useAuthentik()
|
const { user, isAuthenticated, logout } = useAuthentik()
|
||||||
|
|
||||||
|
// Obtener el contador de notificaciones no leídas
|
||||||
|
const { unreadCount } = useNotifications()
|
||||||
|
|
||||||
// Computed para el avatar del usuario
|
// Computed para el avatar del usuario
|
||||||
const userAvatar = computed(() => ({
|
const userAvatar = computed(() => ({
|
||||||
src: user.value?.avatar || `https://ui-avatars.com/api/?name=${encodeURIComponent(user.value?.name || user.value?.username || 'User')}&background=c08040&color=1b1209&bold=true&format=svg`,
|
src: user.value?.avatar || `https://ui-avatars.com/api/?name=${encodeURIComponent(user.value?.name || user.value?.username || 'User')}&background=c08040&color=1b1209&bold=true&format=svg`,
|
||||||
|
|||||||
267
nuxt4-app/app/composables/useNotifications.ts
Normal file
267
nuxt4-app/app/composables/useNotifications.ts
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import type { Ref } from 'vue'
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string
|
||||||
|
type: 'info' | 'warning' | 'success' | 'error'
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
timestamp: number
|
||||||
|
read: boolean
|
||||||
|
origin: 'toast' | 'backend' | 'manual'
|
||||||
|
metadata?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifications: Ref<Notification[]> = ref([])
|
||||||
|
const unreadCount = computed(() => notifications.value.filter(n => !n.read).length)
|
||||||
|
|
||||||
|
// Días para retención de notificaciones
|
||||||
|
const RETENTION_DAYS = 30
|
||||||
|
const STORAGE_KEY_PREFIX = 'notifications-'
|
||||||
|
|
||||||
|
let currentUserId: string | null = null
|
||||||
|
let isInitialized = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la clave de localStorage para el usuario actual
|
||||||
|
*/
|
||||||
|
function getStorageKey(userId: string): string {
|
||||||
|
return `${STORAGE_KEY_PREFIX}${userId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carga las notificaciones desde localStorage
|
||||||
|
*/
|
||||||
|
function loadNotifications(userId: string): Notification[] {
|
||||||
|
if (process.client) {
|
||||||
|
const key = getStorageKey(userId)
|
||||||
|
const stored = localStorage.getItem(key)
|
||||||
|
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(stored) as Notification[]
|
||||||
|
// Limpiar notificaciones antiguas (> RETENTION_DAYS días)
|
||||||
|
const cutoffTime = Date.now() - (RETENTION_DAYS * 24 * 60 * 60 * 1000)
|
||||||
|
return parsed.filter(n => n.timestamp > cutoffTime)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al parsear notificaciones:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guarda las notificaciones en localStorage
|
||||||
|
*/
|
||||||
|
function saveNotifications(userId: string, notifs: Notification[]): void {
|
||||||
|
if (process.client) {
|
||||||
|
const key = getStorageKey(userId)
|
||||||
|
localStorage.setItem(key, JSON.stringify(notifs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpia las notificaciones del usuario actual del localStorage
|
||||||
|
*/
|
||||||
|
function clearStorageForUser(userId: string): void {
|
||||||
|
if (process.client) {
|
||||||
|
const key = getStorageKey(userId)
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sincronización entre pestañas
|
||||||
|
*/
|
||||||
|
function setupStorageSync(userId: string): void {
|
||||||
|
if (process.client) {
|
||||||
|
const key = getStorageKey(userId)
|
||||||
|
|
||||||
|
const handleStorageChange = (event: StorageEvent) => {
|
||||||
|
if (event.key === key && event.newValue) {
|
||||||
|
try {
|
||||||
|
notifications.value = JSON.parse(event.newValue)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al sincronizar notificaciones:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('storage', handleStorageChange)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('storage', handleStorageChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable principal para gestionar notificaciones
|
||||||
|
*/
|
||||||
|
export function useNotifications() {
|
||||||
|
const { user } = useAuthentik()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inicializa el sistema de notificaciones para el usuario actual
|
||||||
|
*/
|
||||||
|
function initialize(): void {
|
||||||
|
if (!user.value || !user.value.uid) {
|
||||||
|
console.warn('No se puede inicializar notificaciones sin usuario autenticado')
|
||||||
|
notifications.value = []
|
||||||
|
currentUserId = null
|
||||||
|
isInitialized = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = user.value.uid
|
||||||
|
|
||||||
|
// Si cambia el usuario, limpiar y recargar
|
||||||
|
if (currentUserId !== userId) {
|
||||||
|
currentUserId = userId
|
||||||
|
notifications.value = loadNotifications(userId)
|
||||||
|
|
||||||
|
// Guardar automáticamente cuando cambien las notificaciones
|
||||||
|
watch(notifications, (newNotifications) => {
|
||||||
|
if (currentUserId) {
|
||||||
|
saveNotifications(currentUserId, newNotifications)
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// Configurar sincronización entre pestañas
|
||||||
|
setupStorageSync(userId)
|
||||||
|
|
||||||
|
isInitialized = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agrega una nueva notificación
|
||||||
|
*/
|
||||||
|
function addNotification(
|
||||||
|
notification: Omit<Notification, 'id' | 'timestamp' | 'read'>
|
||||||
|
): void {
|
||||||
|
if (!isInitialized) {
|
||||||
|
initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
const newNotification: Notification = {
|
||||||
|
...notification,
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
read: false
|
||||||
|
}
|
||||||
|
|
||||||
|
notifications.value.unshift(newNotification)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marca una notificación como leída
|
||||||
|
*/
|
||||||
|
function markAsRead(notificationId: string): void {
|
||||||
|
const notification = notifications.value.find(n => n.id === notificationId)
|
||||||
|
if (notification) {
|
||||||
|
notification.read = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marca todas las notificaciones como leídas
|
||||||
|
*/
|
||||||
|
function markAllAsRead(): void {
|
||||||
|
notifications.value.forEach(n => n.read = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina una notificación específica
|
||||||
|
*/
|
||||||
|
function removeNotification(notificationId: string): void {
|
||||||
|
const index = notifications.value.findIndex(n => n.id === notificationId)
|
||||||
|
if (index !== -1) {
|
||||||
|
notifications.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina todas las notificaciones
|
||||||
|
*/
|
||||||
|
function clearAll(): void {
|
||||||
|
notifications.value = []
|
||||||
|
if (currentUserId) {
|
||||||
|
clearStorageForUser(currentUserId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina notificaciones antiguas (> RETENTION_DAYS días)
|
||||||
|
*/
|
||||||
|
function cleanupOldNotifications(): void {
|
||||||
|
const cutoffTime = Date.now() - (RETENTION_DAYS * 24 * 60 * 60 * 1000)
|
||||||
|
notifications.value = notifications.value.filter(n => n.timestamp > cutoffTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene notificaciones filtradas
|
||||||
|
*/
|
||||||
|
function getFilteredNotifications(options?: {
|
||||||
|
type?: Notification['type'][]
|
||||||
|
read?: boolean
|
||||||
|
search?: string
|
||||||
|
limit?: number
|
||||||
|
}): Notification[] {
|
||||||
|
let filtered = [...notifications.value]
|
||||||
|
|
||||||
|
if (options?.type && options.type.length > 0) {
|
||||||
|
filtered = filtered.filter(n => options.type!.includes(n.type))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.read !== undefined) {
|
||||||
|
filtered = filtered.filter(n => n.read === options.read)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.search) {
|
||||||
|
const searchLower = options.search.toLowerCase()
|
||||||
|
filtered = filtered.filter(n =>
|
||||||
|
n.title.toLowerCase().includes(searchLower) ||
|
||||||
|
n.message.toLowerCase().includes(searchLower)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.limit) {
|
||||||
|
filtered = filtered.slice(0, options.limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-inicializar cuando haya usuario
|
||||||
|
if (user.value && user.value.uid) {
|
||||||
|
initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-inicializar si cambia el usuario
|
||||||
|
watch(() => user.value?.uid, (newUid) => {
|
||||||
|
if (newUid) {
|
||||||
|
initialize()
|
||||||
|
} else {
|
||||||
|
notifications.value = []
|
||||||
|
currentUserId = null
|
||||||
|
isInitialized = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications: computed(() => notifications.value),
|
||||||
|
unreadCount,
|
||||||
|
addNotification,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
removeNotification,
|
||||||
|
clearAll,
|
||||||
|
cleanupOldNotifications,
|
||||||
|
getFilteredNotifications,
|
||||||
|
initialize
|
||||||
|
}
|
||||||
|
}
|
||||||
80
nuxt4-app/app/composables/useToast.ts
Normal file
80
nuxt4-app/app/composables/useToast.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Wrapper del composable useToast de Nuxt UI que intercepta
|
||||||
|
* automáticamente los toasts para guardarlos en el historial
|
||||||
|
* de notificaciones del usuario actual.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ToastOptions {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
color?: 'success' | 'error' | 'info' | 'warning' | 'neutral' | string
|
||||||
|
icon?: string
|
||||||
|
actions?: Array<{ label: string; onClick: () => void }>
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapea los colores de toast a tipos de notificación
|
||||||
|
*/
|
||||||
|
function mapColorToType(color?: string): 'info' | 'warning' | 'success' | 'error' {
|
||||||
|
if (!color) return 'info'
|
||||||
|
|
||||||
|
switch (color) {
|
||||||
|
case 'success':
|
||||||
|
case 'green':
|
||||||
|
return 'success'
|
||||||
|
case 'error':
|
||||||
|
case 'red':
|
||||||
|
return 'error'
|
||||||
|
case 'warning':
|
||||||
|
case 'yellow':
|
||||||
|
case 'orange':
|
||||||
|
return 'warning'
|
||||||
|
case 'info':
|
||||||
|
case 'blue':
|
||||||
|
case 'neutral':
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook para usar toasts con guardado automático en historial
|
||||||
|
*/
|
||||||
|
export function useToast() {
|
||||||
|
// Obtener el toast original de Nuxt UI desde el auto-import
|
||||||
|
const nuxtToast = (globalThis as any).useToast?.() || { add: () => {} }
|
||||||
|
|
||||||
|
const { addNotification } = useNotifications()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agrega un toast y lo guarda en el historial
|
||||||
|
*/
|
||||||
|
function add(options: ToastOptions) {
|
||||||
|
// Llamar al toast original de Nuxt UI
|
||||||
|
nuxtToast.add(options)
|
||||||
|
|
||||||
|
// Guardar en el historial de notificaciones
|
||||||
|
try {
|
||||||
|
addNotification({
|
||||||
|
type: mapColorToType(options.color),
|
||||||
|
title: options.title,
|
||||||
|
message: options.description || '',
|
||||||
|
origin: 'toast',
|
||||||
|
metadata: {
|
||||||
|
icon: options.icon,
|
||||||
|
color: options.color,
|
||||||
|
hasActions: !!options.actions?.length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al guardar notificación en historial:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
add,
|
||||||
|
// Exponer otros métodos del toast original si existen
|
||||||
|
...nuxtToast
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,42 +1,98 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { Notification } from '~/composables/useNotifications'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'dashboard',
|
layout: 'dashboard',
|
||||||
title: 'Notificaciones'
|
title: 'Notificaciones'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mock notifications for preview
|
const {
|
||||||
const mockNotifications = [
|
notifications,
|
||||||
{
|
unreadCount,
|
||||||
id: 1,
|
markAsRead,
|
||||||
type: 'info',
|
markAllAsRead,
|
||||||
title: 'Nueva actualización disponible',
|
removeNotification,
|
||||||
message: 'Se ha lanzado una nueva versión del sistema con mejoras de rendimiento.',
|
clearAll,
|
||||||
time: '2 horas',
|
getFilteredNotifications
|
||||||
read: false,
|
} = useNotifications()
|
||||||
icon: 'i-lucide-info',
|
|
||||||
color: 'blue'
|
// Estado de filtros y búsqueda
|
||||||
},
|
const searchQuery = ref('')
|
||||||
{
|
const selectedTypes = ref<Array<'info' | 'warning' | 'success' | 'error'>>([])
|
||||||
id: 2,
|
|
||||||
type: 'warning',
|
// Opciones de tipos de notificación
|
||||||
title: 'Mantenimiento programado',
|
const typeOptions = [
|
||||||
message: 'El sistema estará en mantenimiento el próximo domingo de 2:00 AM a 4:00 AM.',
|
{ value: 'info', label: 'Información', icon: 'i-lucide-info', color: 'blue' },
|
||||||
time: '1 día',
|
{ value: 'success', label: 'Éxito', icon: 'i-lucide-check-circle', color: 'green' },
|
||||||
read: false,
|
{ value: 'warning', label: 'Advertencia', icon: 'i-lucide-alert-triangle', color: 'amber' },
|
||||||
icon: 'i-lucide-alert-triangle',
|
{ value: 'error', label: 'Error', icon: 'i-lucide-x-circle', color: 'red' }
|
||||||
color: 'amber'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
type: 'success',
|
|
||||||
title: 'Reporte generado',
|
|
||||||
message: 'Tu reporte mensual ha sido generado exitosamente.',
|
|
||||||
time: '3 días',
|
|
||||||
read: true,
|
|
||||||
icon: 'i-lucide-check-circle',
|
|
||||||
color: 'green'
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Notificaciones filtradas
|
||||||
|
const filteredNotifications = computed(() => {
|
||||||
|
return getFilteredNotifications({
|
||||||
|
type: selectedTypes.value.length > 0 ? selectedTypes.value : undefined,
|
||||||
|
search: searchQuery.value || undefined
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verificar si hay notificaciones
|
||||||
|
const hasNotifications = computed(() => notifications.value.length > 0)
|
||||||
|
|
||||||
|
// Mapeo de tipos a iconos y colores
|
||||||
|
function getNotificationStyle(type: Notification['type']) {
|
||||||
|
const option = typeOptions.find(o => o.value === type)
|
||||||
|
return option || typeOptions[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formatear tiempo relativo
|
||||||
|
function formatRelativeTime(timestamp: number): string {
|
||||||
|
const now = Date.now()
|
||||||
|
const diff = now - timestamp
|
||||||
|
const minutes = Math.floor(diff / 60000)
|
||||||
|
const hours = Math.floor(diff / 3600000)
|
||||||
|
const days = Math.floor(diff / 86400000)
|
||||||
|
|
||||||
|
if (minutes < 1) return 'ahora'
|
||||||
|
if (minutes < 60) return `hace ${minutes} min`
|
||||||
|
if (hours < 24) return `hace ${hours}h`
|
||||||
|
if (days < 30) return `hace ${days} días`
|
||||||
|
return new Date(timestamp).toLocaleDateString('es-ES', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manejar clic en notificación (marcar como leída)
|
||||||
|
function handleNotificationClick(notification: Notification) {
|
||||||
|
if (!notification.read) {
|
||||||
|
markAsRead(notification.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmar antes de eliminar todas
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
function confirmClearAll() {
|
||||||
|
if (confirm('¿Estás seguro de que quieres eliminar todas las notificaciones?')) {
|
||||||
|
clearAll()
|
||||||
|
toast.add({
|
||||||
|
title: 'Notificaciones eliminadas',
|
||||||
|
description: 'Se han eliminado todas las notificaciones',
|
||||||
|
color: 'success'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eliminar notificación individual
|
||||||
|
function handleRemove(notification: Notification) {
|
||||||
|
removeNotification(notification.id)
|
||||||
|
toast.add({
|
||||||
|
title: 'Notificación eliminada',
|
||||||
|
color: 'info'
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -44,103 +100,231 @@ const mockNotifications = [
|
|||||||
<UDashboardPanel grow>
|
<UDashboardPanel grow>
|
||||||
<UDashboardNavbar
|
<UDashboardNavbar
|
||||||
title="Notificaciones"
|
title="Notificaciones"
|
||||||
description="Mantente al día con las últimas actualizaciones y alertas"
|
description="Historial de notificaciones y alertas del sistema"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UDashboardPanelContent>
|
<UDashboardPanelContent>
|
||||||
<div class="max-w-4xl mx-auto space-y-8">
|
<div class="max-w-4xl mx-auto space-y-6">
|
||||||
<!-- Coming Soon Banner -->
|
<!-- Header con acciones -->
|
||||||
<UCard>
|
<div v-if="hasNotifications" class="flex flex-col sm:flex-row gap-4">
|
||||||
<div class="text-center py-12 space-y-6">
|
<!-- Búsqueda -->
|
||||||
<div class="flex justify-center">
|
<div class="flex-1">
|
||||||
<div class="w-24 h-24 rounded-full bg-amber-50 dark:bg-amber-950/30 flex items-center justify-center relative">
|
<UInput
|
||||||
<UIcon name="i-lucide-bell" class="size-12 text-amber-600 dark:text-amber-400" />
|
v-model="searchQuery"
|
||||||
<span class="absolute -top-1 -right-1 w-6 h-6 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center ring-4 ring-white dark:ring-gray-900">
|
icon="i-lucide-search"
|
||||||
3
|
placeholder="Buscar notificaciones..."
|
||||||
</span>
|
size="lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Acciones -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<UButton
|
||||||
|
v-if="unreadCount > 0"
|
||||||
|
color="neutral"
|
||||||
|
variant="soft"
|
||||||
|
@click="markAllAsRead"
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<UIcon name="i-lucide-check-check" />
|
||||||
|
</template>
|
||||||
|
Marcar todas como leídas
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="soft"
|
||||||
|
@click="confirmClearAll"
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<UIcon name="i-lucide-trash-2" />
|
||||||
|
</template>
|
||||||
|
Eliminar todas
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filtros por tipo -->
|
||||||
|
<div v-if="hasNotifications" class="flex gap-2 flex-wrap">
|
||||||
|
<UButton
|
||||||
|
v-for="option in typeOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:color="selectedTypes.includes(option.value as any) ? option.color : 'neutral'"
|
||||||
|
:variant="selectedTypes.includes(option.value as any) ? 'solid' : 'soft'"
|
||||||
|
size="sm"
|
||||||
|
@click="() => {
|
||||||
|
const idx = selectedTypes.indexOf(option.value as any)
|
||||||
|
if (idx === -1) {
|
||||||
|
selectedTypes.push(option.value as any)
|
||||||
|
} else {
|
||||||
|
selectedTypes.splice(idx, 1)
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<UIcon :name="option.icon" />
|
||||||
|
</template>
|
||||||
|
{{ option.label }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Badge de contador -->
|
||||||
|
<div v-if="hasNotifications" class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-[var(--brand-text-muted)]">
|
||||||
|
{{ filteredNotifications.length }} notificación{{ filteredNotifications.length !== 1 ? 'es' : '' }}
|
||||||
|
</span>
|
||||||
|
<UBadge v-if="unreadCount > 0" color="red" variant="solid" size="sm">
|
||||||
|
{{ unreadCount }} nueva{{ unreadCount !== 1 ? 's' : '' }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lista de notificaciones -->
|
||||||
|
<UCard v-if="filteredNotifications.length > 0">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="notification in filteredNotifications"
|
||||||
|
:key="notification.id"
|
||||||
|
:class="[
|
||||||
|
'p-4 rounded-lg border transition-all cursor-pointer group',
|
||||||
|
notification.read
|
||||||
|
? 'bg-gray-50/50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700'
|
||||||
|
: 'bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
|
||||||
|
]"
|
||||||
|
@click="handleNotificationClick(notification)"
|
||||||
|
>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<!-- Icono -->
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0',
|
||||||
|
`bg-${getNotificationStyle(notification.type).color}-50 dark:bg-${getNotificationStyle(notification.type).color}-950/30`
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<UIcon
|
||||||
|
:name="getNotificationStyle(notification.type).icon"
|
||||||
|
:class="[
|
||||||
|
'size-5',
|
||||||
|
`text-${getNotificationStyle(notification.type).color}-600 dark:text-${getNotificationStyle(notification.type).color}-400`
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contenido -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<h4
|
||||||
|
:class="[
|
||||||
|
'font-semibold text-sm',
|
||||||
|
notification.read
|
||||||
|
? 'text-gray-600 dark:text-[var(--brand-text-muted)]'
|
||||||
|
: 'text-gray-900 dark:text-white'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ notification.title }}
|
||||||
|
</h4>
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-500">
|
||||||
|
{{ formatRelativeTime(notification.timestamp) }}
|
||||||
|
</span>
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
icon="i-lucide-x"
|
||||||
|
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
@click.stop="handleRemove(notification)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="notification.message"
|
||||||
|
class="text-sm text-gray-600 dark:text-[var(--brand-text-muted)] mt-1"
|
||||||
|
>
|
||||||
|
{{ notification.message }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Badges de origen -->
|
||||||
|
<div class="flex gap-2 mt-2">
|
||||||
|
<UBadge
|
||||||
|
v-if="notification.origin === 'toast'"
|
||||||
|
color="neutral"
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<UIcon name="i-lucide-message-square" class="size-3" />
|
||||||
|
</template>
|
||||||
|
Toast
|
||||||
|
</UBadge>
|
||||||
|
<UBadge
|
||||||
|
v-else-if="notification.origin === 'backend'"
|
||||||
|
color="blue"
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<UIcon name="i-lucide-server" class="size-3" />
|
||||||
|
</template>
|
||||||
|
Sistema
|
||||||
|
</UBadge>
|
||||||
|
<UBadge
|
||||||
|
v-else-if="notification.origin === 'manual'"
|
||||||
|
color="purple"
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
>
|
||||||
|
<template #leading>
|
||||||
|
<UIcon name="i-lucide-user" class="size-3" />
|
||||||
|
</template>
|
||||||
|
Manual
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
|
||||||
Página en construcción
|
|
||||||
</h2>
|
|
||||||
<p class="text-lg text-gray-600 dark:text-[var(--brand-text-muted)] mb-4">
|
|
||||||
Estamos trabajando en esta funcionalidad
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-500 max-w-md mx-auto">
|
|
||||||
Pronto podrás gestionar tus notificaciones, configurar alertas personalizadas y mantenerte informado sobre eventos importantes del sistema.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-center gap-3">
|
|
||||||
<UButton
|
|
||||||
to="/"
|
|
||||||
color="primary"
|
|
||||||
icon="i-lucide-home"
|
|
||||||
>
|
|
||||||
Volver al inicio
|
|
||||||
</UButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
<!-- Notifications Preview -->
|
<!-- Estado vacío cuando hay filtros activos -->
|
||||||
<UCard>
|
<UCard
|
||||||
<template #header>
|
v-else-if="hasNotifications && filteredNotifications.length === 0"
|
||||||
<div class="flex items-center justify-between">
|
class="text-center py-12"
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
>
|
||||||
Vista previa de notificaciones
|
<div class="flex flex-col items-center gap-4">
|
||||||
</h3>
|
<div class="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||||
<UBadge color="red" variant="solid" size="sm">
|
<UIcon name="i-lucide-search-x" class="size-8 text-gray-400 dark:text-gray-600" />
|
||||||
{{ mockNotifications.filter(n => !n.read).length }} nuevas
|
|
||||||
</UBadge>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||||
<div class="space-y-3">
|
No se encontraron notificaciones
|
||||||
<div
|
</h3>
|
||||||
v-for="notification in mockNotifications"
|
<p class="text-sm text-gray-600 dark:text-[var(--brand-text-muted)]">
|
||||||
:key="notification.id"
|
Intenta ajustar los filtros o la búsqueda
|
||||||
:class="[
|
</p>
|
||||||
'p-4 rounded-lg border transition-colors',
|
</div>
|
||||||
notification.read
|
<UButton
|
||||||
? 'bg-gray-50/50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700'
|
color="neutral"
|
||||||
: 'bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-600'
|
variant="soft"
|
||||||
]"
|
@click="() => { searchQuery = ''; selectedTypes = [] }"
|
||||||
>
|
>
|
||||||
<div class="flex gap-3">
|
Limpiar filtros
|
||||||
<div :class="[
|
</UButton>
|
||||||
'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0',
|
</div>
|
||||||
`bg-${notification.color}-50 dark:bg-${notification.color}-950/30`
|
</UCard>
|
||||||
]">
|
|
||||||
<UIcon
|
<!-- Estado vacío cuando no hay notificaciones -->
|
||||||
:name="notification.icon"
|
<UCard v-else class="text-center py-12">
|
||||||
:class="[
|
<div class="flex flex-col items-center gap-4">
|
||||||
'size-5',
|
<div class="w-20 h-20 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
|
||||||
`text-${notification.color}-600 dark:text-${notification.color}-400`
|
<UIcon name="i-lucide-bell-off" class="size-10 text-gray-400 dark:text-gray-600" />
|
||||||
]"
|
</div>
|
||||||
/>
|
<div>
|
||||||
</div>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||||
<div class="flex-1 min-w-0">
|
No tienes notificaciones
|
||||||
<div class="flex items-start justify-between gap-2">
|
</h3>
|
||||||
<h4 :class="[
|
<p class="text-sm text-gray-600 dark:text-[var(--brand-text-muted)]">
|
||||||
'font-semibold text-sm',
|
Cuando recibas notificaciones, aparecerán aquí
|
||||||
notification.read
|
</p>
|
||||||
? 'text-gray-600 dark:text-[var(--brand-text-muted)]'
|
|
||||||
: 'text-gray-900 dark:text-white'
|
|
||||||
]">
|
|
||||||
{{ notification.title }}
|
|
||||||
</h4>
|
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-500 flex-shrink-0">
|
|
||||||
hace {{ notification.time }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-[var(--brand-text-muted)] mt-1">
|
|
||||||
{{ notification.message }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|||||||
71
nuxt4-app/server/api/notifications/list.get.ts
Normal file
71
nuxt4-app/server/api/notifications/list.get.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Endpoint para obtener notificaciones pendientes para el usuario actual
|
||||||
|
*
|
||||||
|
* Este endpoint permite al cliente hacer polling para obtener notificaciones
|
||||||
|
* generadas por el backend que aún no han sido entregadas.
|
||||||
|
*
|
||||||
|
* Casos de uso:
|
||||||
|
* - Polling periódico para obtener nuevas notificaciones
|
||||||
|
* - Sincronización inicial al cargar la aplicación
|
||||||
|
*/
|
||||||
|
|
||||||
|
// En memoria: cola de notificaciones por usuario
|
||||||
|
// Nota: En producción, esto debería estar en Redis o una BD
|
||||||
|
const notificationQueue = new Map<string, any[]>()
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Leer el usuario actual de los headers de Authentik
|
||||||
|
const headers = getHeaders(event)
|
||||||
|
const userId = headers['x-authentik-uid']
|
||||||
|
const username = headers['x-authentik-username']
|
||||||
|
|
||||||
|
// Verificar que el usuario esté autenticado
|
||||||
|
if (!userId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Usuario no autenticado'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener notificaciones pendientes para este usuario
|
||||||
|
const pendingNotifications = notificationQueue.get(userId) || []
|
||||||
|
|
||||||
|
// Limpiar la cola después de entregarlas
|
||||||
|
notificationQueue.delete(userId)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
notifications: pendingNotifications,
|
||||||
|
count: pendingNotifications.length,
|
||||||
|
user: {
|
||||||
|
uid: userId,
|
||||||
|
username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error al obtener notificaciones:', error)
|
||||||
|
|
||||||
|
// Si ya es un error de H3, relanzarlo
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error genérico
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Error al obtener notificaciones'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Función helper para agregar una notificación a la cola de un usuario
|
||||||
|
* Esta función puede ser llamada desde otros endpoints del servidor
|
||||||
|
*/
|
||||||
|
export function queueNotification(userId: string, notification: any) {
|
||||||
|
if (!notificationQueue.has(userId)) {
|
||||||
|
notificationQueue.set(userId, [])
|
||||||
|
}
|
||||||
|
notificationQueue.get(userId)!.push(notification)
|
||||||
|
}
|
||||||
95
nuxt4-app/server/api/notifications/send.post.ts
Normal file
95
nuxt4-app/server/api/notifications/send.post.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Endpoint para enviar notificaciones a usuarios específicos desde el backend
|
||||||
|
*
|
||||||
|
* Esta API permite crear notificaciones que se entregarán directamente
|
||||||
|
* al cliente para ser guardadas en su localStorage.
|
||||||
|
*
|
||||||
|
* Casos de uso:
|
||||||
|
* - Notificaciones del sistema
|
||||||
|
* - Alertas generadas por procesos del backend
|
||||||
|
* - Notificaciones administrativas
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
// Leer el body de la petición
|
||||||
|
const body = await readBody(event)
|
||||||
|
|
||||||
|
// Validar campos requeridos
|
||||||
|
if (!body.targetUserId || !body.title) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: 'Faltan campos requeridos: targetUserId y title'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar tipo de notificación
|
||||||
|
const validTypes = ['info', 'warning', 'success', 'error']
|
||||||
|
const type = body.type || 'info'
|
||||||
|
if (!validTypes.includes(type)) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: `Tipo inválido. Debe ser uno de: ${validTypes.join(', ')}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leer el usuario actual de los headers de Authentik
|
||||||
|
const headers = getHeaders(event)
|
||||||
|
const currentUserId = headers['x-authentik-uid']
|
||||||
|
const currentUsername = headers['x-authentik-username']
|
||||||
|
|
||||||
|
// Verificar que el usuario esté autenticado
|
||||||
|
if (!currentUserId) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Usuario no autenticado'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opcional: verificar permisos (por ejemplo, solo admins pueden enviar notificaciones)
|
||||||
|
// const groups = headers['x-authentik-groups']?.split('|') || []
|
||||||
|
// if (!groups.includes('admins')) {
|
||||||
|
// throw createError({
|
||||||
|
// statusCode: 403,
|
||||||
|
// statusMessage: 'No tienes permisos para enviar notificaciones'
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Construir la notificación
|
||||||
|
const notification = {
|
||||||
|
targetUserId: body.targetUserId,
|
||||||
|
type: type as 'info' | 'warning' | 'success' | 'error',
|
||||||
|
title: body.title,
|
||||||
|
message: body.message || '',
|
||||||
|
origin: 'backend' as const,
|
||||||
|
metadata: {
|
||||||
|
sentBy: currentUsername,
|
||||||
|
sentAt: new Date().toISOString(),
|
||||||
|
...body.metadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nota: Como usamos localStorage en el cliente, no guardamos en BD
|
||||||
|
// En su lugar, retornamos la notificación para que el cliente la guarde
|
||||||
|
// El cliente debe hacer polling o usar SSE/WebSocket para obtener notificaciones
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
notification,
|
||||||
|
message: 'Notificación creada correctamente'
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error al enviar notificación:', error)
|
||||||
|
|
||||||
|
// Si ya es un error de H3, relanzarlo
|
||||||
|
if (error.statusCode) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error genérico
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Error al procesar la notificación'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user