Files
analiticaNucleo/nuxt4-app/app/pages/notifications.vue
josedario87 b6dc08e599
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 48s
Feat: Implementar sistema de notificaciones con historial por usuario
- 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
2025-10-30 18:03:37 -06:00

336 lines
12 KiB
Vue

<script setup lang="ts">
import type { Notification } from '~/composables/useNotifications'
definePageMeta({
layout: 'dashboard',
title: 'Notificaciones'
})
const {
notifications,
unreadCount,
markAsRead,
markAllAsRead,
removeNotification,
clearAll,
getFilteredNotifications
} = useNotifications()
// Estado de filtros y búsqueda
const searchQuery = ref('')
const selectedTypes = ref<Array<'info' | 'warning' | 'success' | 'error'>>([])
// Opciones de tipos de notificación
const typeOptions = [
{ value: 'info', label: 'Información', icon: 'i-lucide-info', color: 'blue' },
{ value: 'success', label: 'Éxito', icon: 'i-lucide-check-circle', color: 'green' },
{ value: 'warning', label: 'Advertencia', icon: 'i-lucide-alert-triangle', color: 'amber' },
{ value: 'error', label: 'Error', icon: 'i-lucide-x-circle', color: 'red' }
]
// 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>
<template>
<UDashboardLayout>
<UDashboardPanel grow>
<UDashboardNavbar
title="Notificaciones"
description="Historial de notificaciones y alertas del sistema"
/>
<UDashboardPanelContent>
<div class="max-w-4xl mx-auto space-y-6">
<!-- Header con acciones -->
<div v-if="hasNotifications" class="flex flex-col sm:flex-row gap-4">
<!-- Búsqueda -->
<div class="flex-1">
<UInput
v-model="searchQuery"
icon="i-lucide-search"
placeholder="Buscar notificaciones..."
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>
</UCard>
<!-- Estado vacío cuando hay filtros activos -->
<UCard
v-else-if="hasNotifications && filteredNotifications.length === 0"
class="text-center py-12"
>
<div class="flex flex-col items-center gap-4">
<div class="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
<UIcon name="i-lucide-search-x" class="size-8 text-gray-400 dark:text-gray-600" />
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
No se encontraron notificaciones
</h3>
<p class="text-sm text-gray-600 dark:text-[var(--brand-text-muted)]">
Intenta ajustar los filtros o la búsqueda
</p>
</div>
<UButton
color="neutral"
variant="soft"
@click="() => { searchQuery = ''; selectedTypes = [] }"
>
Limpiar filtros
</UButton>
</div>
</UCard>
<!-- Estado vacío cuando no hay notificaciones -->
<UCard v-else class="text-center py-12">
<div class="flex flex-col items-center gap-4">
<div class="w-20 h-20 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
<UIcon name="i-lucide-bell-off" class="size-10 text-gray-400 dark:text-gray-600" />
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
No tienes notificaciones
</h3>
<p class="text-sm text-gray-600 dark:text-[var(--brand-text-muted)]">
Cuando recibas notificaciones, aparecerán aquí
</p>
</div>
</div>
</UCard>
</div>
</UDashboardPanelContent>
</UDashboardPanel>
</UDashboardLayout>
</template>