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
336 lines
12 KiB
Vue
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>
|