diff --git a/src/features/notifications/api/index.ts b/src/features/notifications/api/index.ts new file mode 100644 index 0000000..a2d2e5c --- /dev/null +++ b/src/features/notifications/api/index.ts @@ -0,0 +1 @@ +export * from './notifications.api'; diff --git a/src/features/notifications/api/notifications.api.ts b/src/features/notifications/api/notifications.api.ts new file mode 100644 index 0000000..474f47b --- /dev/null +++ b/src/features/notifications/api/notifications.api.ts @@ -0,0 +1,148 @@ +import { api } from '@services/api/axios-instance'; +import type { + NotificationChannel, + NotificationTemplate, + NotificationTemplateCreateInput, + NotificationPreference, + NotificationPreferenceUpdateInput, + Notification, + NotificationCreateInput, + NotificationStatus, + InAppNotification, + InAppNotificationCreateInput, + InAppNotificationsFilters, + InAppNotificationsResponse, +} from '../types'; + +const NOTIFICATIONS_BASE = '/api/v1/notifications'; + +// ============================================================================ +// Channels API +// ============================================================================ + +export const channelsApi = { + getAll: async (): Promise => { + const response = await api.get(`${NOTIFICATIONS_BASE}/channels`); + return response.data; + }, + + getByCode: async (code: string): Promise => { + const response = await api.get(`${NOTIFICATIONS_BASE}/channels/${code}`); + return response.data; + }, +}; + +// ============================================================================ +// Templates API +// ============================================================================ + +export const templatesApi = { + getAll: async (): Promise => { + const response = await api.get(`${NOTIFICATIONS_BASE}/templates`); + return response.data; + }, + + getByCode: async (code: string): Promise => { + const response = await api.get(`${NOTIFICATIONS_BASE}/templates/${code}`); + return response.data; + }, + + create: async (data: NotificationTemplateCreateInput): Promise => { + const response = await api.post(`${NOTIFICATIONS_BASE}/templates`, data); + return response.data; + }, + + update: async (id: string, data: Partial): Promise => { + const response = await api.patch(`${NOTIFICATIONS_BASE}/templates/${id}`, data); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`${NOTIFICATIONS_BASE}/templates/${id}`); + }, +}; + +// ============================================================================ +// Preferences API +// ============================================================================ + +export const preferencesApi = { + get: async (): Promise => { + const response = await api.get(`${NOTIFICATIONS_BASE}/preferences`); + return response.data; + }, + + update: async (data: NotificationPreferenceUpdateInput): Promise => { + const response = await api.patch(`${NOTIFICATIONS_BASE}/preferences`, data); + return response.data; + }, +}; + +// ============================================================================ +// Notifications API +// ============================================================================ + +export const notificationsApi = { + create: async (data: NotificationCreateInput): Promise => { + const response = await api.post(`${NOTIFICATIONS_BASE}`, data); + return response.data; + }, + + getPending: async (limit = 50): Promise => { + const response = await api.get(`${NOTIFICATIONS_BASE}/pending?limit=${limit}`); + return response.data; + }, + + updateStatus: async (id: string, status: NotificationStatus, errorMessage?: string): Promise => { + const response = await api.patch(`${NOTIFICATIONS_BASE}/${id}/status`, { + status, + errorMessage, + }); + return response.data; + }, +}; + +// ============================================================================ +// In-App Notifications API +// ============================================================================ + +export const inAppApi = { + getAll: async (filters: InAppNotificationsFilters = {}): Promise => { + const params = new URLSearchParams(); + if (filters.includeRead !== undefined) params.append('include_read', String(filters.includeRead)); + if (filters.category) params.append('category', filters.category); + if (filters.page) params.append('page', String(filters.page)); + if (filters.limit) params.append('limit', String(filters.limit)); + + const response = await api.get(`${NOTIFICATIONS_BASE}/in-app?${params}`); + return response.data; + }, + + getUnreadCount: async (): Promise => { + const response = await api.get<{ count: number }>(`${NOTIFICATIONS_BASE}/in-app/unread-count`); + return response.data.count; + }, + + markAsRead: async (id: string): Promise => { + const response = await api.post(`${NOTIFICATIONS_BASE}/in-app/${id}/read`); + return response.data; + }, + + markAllAsRead: async (): Promise => { + await api.post(`${NOTIFICATIONS_BASE}/in-app/read-all`); + }, + + create: async (data: InAppNotificationCreateInput): Promise => { + const response = await api.post(`${NOTIFICATIONS_BASE}/in-app`, data); + return response.data; + }, + + archive: async (id: string): Promise => { + const response = await api.post(`${NOTIFICATIONS_BASE}/in-app/${id}/archive`); + return response.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`${NOTIFICATIONS_BASE}/in-app/${id}`); + }, +}; diff --git a/src/features/notifications/hooks/index.ts b/src/features/notifications/hooks/index.ts new file mode 100644 index 0000000..f158ee0 --- /dev/null +++ b/src/features/notifications/hooks/index.ts @@ -0,0 +1,7 @@ +export { + useChannels, + useTemplates, + useNotificationPreferences, + useInAppNotifications, + useNotificationBell, +} from './useNotifications'; diff --git a/src/features/notifications/hooks/useNotifications.ts b/src/features/notifications/hooks/useNotifications.ts new file mode 100644 index 0000000..c83c720 --- /dev/null +++ b/src/features/notifications/hooks/useNotifications.ts @@ -0,0 +1,356 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + channelsApi, + templatesApi, + preferencesApi, + inAppApi, +} from '../api'; +import type { + NotificationChannel, + NotificationTemplate, + NotificationTemplateCreateInput, + NotificationPreference, + NotificationPreferenceUpdateInput, + InAppNotification, + InAppNotificationsFilters, +} from '../types'; + +// ============================================================================ +// Channels Hook +// ============================================================================ + +interface UseChannelsReturn { + channels: NotificationChannel[]; + isLoading: boolean; + error: Error | null; + refresh: () => Promise; + getByCode: (code: string) => Promise; +} + +export function useChannels(): UseChannelsReturn { + const [channels, setChannels] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchChannels = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const data = await channelsApi.getAll(); + setChannels(data); + } catch (err) { + setError(err instanceof Error ? err : new Error('Error fetching channels')); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchChannels(); + }, [fetchChannels]); + + const getByCode = useCallback(async (code: string): Promise => { + return channelsApi.getByCode(code); + }, []); + + return { + channels, + isLoading, + error, + refresh: fetchChannels, + getByCode, + }; +} + +// ============================================================================ +// Templates Hook +// ============================================================================ + +interface UseTemplatesReturn { + templates: NotificationTemplate[]; + isLoading: boolean; + error: Error | null; + refresh: () => Promise; + getByCode: (code: string) => Promise; + create: (data: NotificationTemplateCreateInput) => Promise; + update: (id: string, data: Partial) => Promise; + remove: (id: string) => Promise; +} + +export function useTemplates(): UseTemplatesReturn { + const [templates, setTemplates] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchTemplates = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const data = await templatesApi.getAll(); + setTemplates(data); + } catch (err) { + setError(err instanceof Error ? err : new Error('Error fetching templates')); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchTemplates(); + }, [fetchTemplates]); + + const getByCode = useCallback(async (code: string): Promise => { + return templatesApi.getByCode(code); + }, []); + + const create = useCallback(async (data: NotificationTemplateCreateInput): Promise => { + const result = await templatesApi.create(data); + await fetchTemplates(); + return result; + }, [fetchTemplates]); + + const update = useCallback(async (id: string, data: Partial): Promise => { + const result = await templatesApi.update(id, data); + await fetchTemplates(); + return result; + }, [fetchTemplates]); + + const remove = useCallback(async (id: string): Promise => { + await templatesApi.delete(id); + await fetchTemplates(); + }, [fetchTemplates]); + + return { + templates, + isLoading, + error, + refresh: fetchTemplates, + getByCode, + create, + update, + remove, + }; +} + +// ============================================================================ +// Preferences Hook +// ============================================================================ + +interface UseNotificationPreferencesReturn { + preferences: NotificationPreference | null; + isLoading: boolean; + isSaving: boolean; + error: Error | null; + refresh: () => Promise; + update: (data: NotificationPreferenceUpdateInput) => Promise; +} + +export function useNotificationPreferences(): UseNotificationPreferencesReturn { + const [preferences, setPreferences] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + + const fetchPreferences = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const data = await preferencesApi.get(); + setPreferences(data); + } catch (err) { + setError(err instanceof Error ? err : new Error('Error fetching preferences')); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchPreferences(); + }, [fetchPreferences]); + + const update = useCallback(async (data: NotificationPreferenceUpdateInput): Promise => { + setIsSaving(true); + setError(null); + try { + const result = await preferencesApi.update(data); + setPreferences(result); + return result; + } catch (err) { + const error = err instanceof Error ? err : new Error('Error updating preferences'); + setError(error); + throw error; + } finally { + setIsSaving(false); + } + }, []); + + return { + preferences, + isLoading, + isSaving, + error, + refresh: fetchPreferences, + update, + }; +} + +// ============================================================================ +// In-App Notifications Hook +// ============================================================================ + +interface UseInAppNotificationsOptions { + initialFilters?: InAppNotificationsFilters; + autoLoad?: boolean; + pollInterval?: number; +} + +interface UseInAppNotificationsReturn { + notifications: InAppNotification[]; + unreadCount: number; + total: number; + page: number; + totalPages: number; + isLoading: boolean; + error: Error | null; + filters: InAppNotificationsFilters; + setFilters: (filters: InAppNotificationsFilters) => void; + refresh: () => Promise; + markAsRead: (id: string) => Promise; + markAllAsRead: () => Promise; + archive: (id: string) => Promise; + remove: (id: string) => Promise; +} + +export function useInAppNotifications( + options: UseInAppNotificationsOptions = {} +): UseInAppNotificationsReturn { + const { initialFilters = {}, autoLoad = true, pollInterval } = options; + + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [filters, setFilters] = useState(initialFilters); + + const fetchNotifications = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const response = await inAppApi.getAll(filters); + setNotifications(response.data); + setUnreadCount(response.unreadCount); + setTotal(response.total); + setPage(response.page); + setTotalPages(response.totalPages); + } catch (err) { + setError(err instanceof Error ? err : new Error('Error fetching notifications')); + } finally { + setIsLoading(false); + } + }, [filters]); + + const fetchUnreadCount = useCallback(async () => { + try { + const count = await inAppApi.getUnreadCount(); + setUnreadCount(count); + } catch (err) { + // Silently fail for unread count polling + } + }, []); + + useEffect(() => { + if (autoLoad) { + fetchNotifications(); + } + }, [autoLoad, fetchNotifications]); + + // Poll for unread count + useEffect(() => { + if (!pollInterval) return; + + const interval = setInterval(fetchUnreadCount, pollInterval); + return () => clearInterval(interval); + }, [pollInterval, fetchUnreadCount]); + + const markAsRead = useCallback(async (id: string): Promise => { + await inAppApi.markAsRead(id); + setNotifications(prev => + prev.map(n => n.id === id ? { ...n, isRead: true, readAt: new Date().toISOString() } : n) + ); + setUnreadCount(prev => Math.max(0, prev - 1)); + }, []); + + const markAllAsRead = useCallback(async (): Promise => { + await inAppApi.markAllAsRead(); + setNotifications(prev => + prev.map(n => ({ ...n, isRead: true, readAt: new Date().toISOString() })) + ); + setUnreadCount(0); + }, []); + + const archive = useCallback(async (id: string): Promise => { + await inAppApi.archive(id); + setNotifications(prev => prev.filter(n => n.id !== id)); + }, []); + + const remove = useCallback(async (id: string): Promise => { + await inAppApi.delete(id); + setNotifications(prev => prev.filter(n => n.id !== id)); + }, []); + + return { + notifications, + unreadCount, + total, + page, + totalPages, + isLoading, + error, + filters, + setFilters, + refresh: fetchNotifications, + markAsRead, + markAllAsRead, + archive, + remove, + }; +} + +// ============================================================================ +// Notification Bell Hook (for header/navbar) +// ============================================================================ + +interface UseNotificationBellReturn { + unreadCount: number; + recentNotifications: InAppNotification[]; + isLoading: boolean; + refresh: () => Promise; + markAsRead: (id: string) => Promise; + markAllAsRead: () => Promise; +} + +export function useNotificationBell(pollInterval = 30000): UseNotificationBellReturn { + const { + notifications: recentNotifications, + unreadCount, + isLoading, + refresh, + markAsRead, + markAllAsRead, + } = useInAppNotifications({ + initialFilters: { limit: 5, includeRead: false }, + pollInterval, + }); + + return { + unreadCount, + recentNotifications, + isLoading, + refresh, + markAsRead, + markAllAsRead, + }; +} diff --git a/src/features/notifications/index.ts b/src/features/notifications/index.ts new file mode 100644 index 0000000..de2c0d4 --- /dev/null +++ b/src/features/notifications/index.ts @@ -0,0 +1,10 @@ +// Notifications Feature - Barrel Export + +// Types +export * from './types'; + +// API +export * from './api'; + +// Hooks +export * from './hooks'; diff --git a/src/features/notifications/types/index.ts b/src/features/notifications/types/index.ts new file mode 100644 index 0000000..6e71b92 --- /dev/null +++ b/src/features/notifications/types/index.ts @@ -0,0 +1 @@ +export * from './notifications.types'; diff --git a/src/features/notifications/types/notifications.types.ts b/src/features/notifications/types/notifications.types.ts new file mode 100644 index 0000000..ff76b70 --- /dev/null +++ b/src/features/notifications/types/notifications.types.ts @@ -0,0 +1,293 @@ +// Notifications Types - Channels, Templates, Preferences, In-App + +// ============================================================================ +// Channel Types +// ============================================================================ + +export type ChannelType = 'email' | 'sms' | 'push' | 'whatsapp' | 'in_app' | 'webhook'; + +export interface NotificationChannel { + id: string; + tenantId: string; + code: string; + name: string; + type: ChannelType; + isActive: boolean; + providerConfig?: Record; + rateLimitPerMinute?: number; + rateLimitPerHour?: number; + createdAt: string; + updatedAt: string; +} + +// ============================================================================ +// Template Types +// ============================================================================ + +export interface NotificationTemplate { + id: string; + tenantId: string; + code: string; + name: string; + description?: string; + channelType: ChannelType; + subject?: string; + body: string; + variables: string[]; + isActive: boolean; + translations?: TemplateTranslation[]; + createdAt: string; + updatedAt: string; +} + +export interface TemplateTranslation { + id: string; + templateId: string; + locale: string; + subject?: string; + body: string; +} + +export interface NotificationTemplateCreateInput { + code: string; + name: string; + description?: string; + channelType: ChannelType; + subject?: string; + body: string; + variables?: string[]; +} + +export interface NotificationTemplateUpdateInput extends Partial { + isActive?: boolean; +} + +// ============================================================================ +// Preference Types +// ============================================================================ + +export interface NotificationPreference { + id: string; + userId: string; + tenantId: string; + emailEnabled: boolean; + smsEnabled: boolean; + pushEnabled: boolean; + whatsappEnabled: boolean; + inAppEnabled: boolean; + quietHoursStart?: string; + quietHoursEnd?: string; + digestEnabled: boolean; + digestFrequency?: 'daily' | 'weekly'; + categoryPreferences?: Record; + createdAt: string; + updatedAt: string; +} + +export interface NotificationPreferenceUpdateInput { + emailEnabled?: boolean; + smsEnabled?: boolean; + pushEnabled?: boolean; + whatsappEnabled?: boolean; + inAppEnabled?: boolean; + quietHoursStart?: string; + quietHoursEnd?: string; + digestEnabled?: boolean; + digestFrequency?: 'daily' | 'weekly'; + categoryPreferences?: Record; +} + +// ============================================================================ +// Notification Types +// ============================================================================ + +export type NotificationStatus = + | 'pending' + | 'queued' + | 'sending' + | 'sent' + | 'delivered' + | 'read' + | 'failed' + | 'cancelled'; + +export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent'; + +export interface Notification { + id: string; + tenantId: string; + userId: string; + templateId?: string; + channelType: ChannelType; + recipient: string; + subject?: string; + body: string; + status: NotificationStatus; + priority: NotificationPriority; + scheduledAt?: string; + sentAt?: string; + deliveredAt?: string; + readAt?: string; + failedAt?: string; + retryCount: number; + maxRetries: number; + lastError?: string; + metadata?: Record; + createdAt: string; + updatedAt: string; +} + +export interface NotificationCreateInput { + userId: string; + templateCode?: string; + channelType: ChannelType; + recipient: string; + subject?: string; + body?: string; + variables?: Record; + priority?: NotificationPriority; + scheduledAt?: string; + metadata?: Record; +} + +// ============================================================================ +// In-App Notification Types +// ============================================================================ + +export type InAppCategory = + | 'system' + | 'order' + | 'payment' + | 'inventory' + | 'user' + | 'alert' + | 'reminder' + | 'other'; + +export interface InAppNotification { + id: string; + tenantId: string; + userId: string; + title: string; + message: string; + category: InAppCategory; + priority: NotificationPriority; + isRead: boolean; + isArchived: boolean; + actionUrl?: string; + actionLabel?: string; + icon?: string; + imageUrl?: string; + metadata?: Record; + expiresAt?: string; + readAt?: string; + createdAt: string; +} + +export interface InAppNotificationCreateInput { + userId: string; + title: string; + message: string; + category?: InAppCategory; + priority?: NotificationPriority; + actionUrl?: string; + actionLabel?: string; + icon?: string; + imageUrl?: string; + metadata?: Record; + expiresAt?: string; +} + +export interface InAppNotificationsFilters { + includeRead?: boolean; + category?: InAppCategory; + page?: number; + limit?: number; +} + +export interface InAppNotificationsResponse { + data: InAppNotification[]; + total: number; + page: number; + limit: number; + totalPages: number; + unreadCount: number; +} + +// ============================================================================ +// Batch Notification Types +// ============================================================================ + +export type BatchStatus = 'draft' | 'scheduled' | 'sending' | 'completed' | 'failed' | 'cancelled'; + +export interface NotificationBatch { + id: string; + tenantId: string; + name: string; + templateId: string; + channelType: ChannelType; + status: BatchStatus; + audienceFilter?: Record; + scheduledAt?: string; + startedAt?: string; + completedAt?: string; + totalCount: number; + sentCount: number; + deliveredCount: number; + failedCount: number; + readCount: number; + createdBy: string; + createdAt: string; + updatedAt: string; +} + +// ============================================================================ +// Constants and Labels +// ============================================================================ + +export const CHANNEL_TYPE_LABELS: Record = { + email: 'Correo electronico', + sms: 'SMS', + push: 'Notificacion push', + whatsapp: 'WhatsApp', + in_app: 'En la aplicacion', + webhook: 'Webhook', +}; + +export const NOTIFICATION_STATUS_LABELS: Record = { + pending: 'Pendiente', + queued: 'En cola', + sending: 'Enviando', + sent: 'Enviado', + delivered: 'Entregado', + read: 'Leido', + failed: 'Fallido', + cancelled: 'Cancelado', +}; + +export const NOTIFICATION_PRIORITY_LABELS: Record = { + low: 'Baja', + normal: 'Normal', + high: 'Alta', + urgent: 'Urgente', +}; + +export const IN_APP_CATEGORY_LABELS: Record = { + system: 'Sistema', + order: 'Pedidos', + payment: 'Pagos', + inventory: 'Inventario', + user: 'Usuarios', + alert: 'Alertas', + reminder: 'Recordatorios', + other: 'Otros', +}; + +export const BATCH_STATUS_LABELS: Record = { + draft: 'Borrador', + scheduled: 'Programado', + sending: 'Enviando', + completed: 'Completado', + failed: 'Fallido', + cancelled: 'Cancelado', +}; diff --git a/src/pages/notifications/NotificationsPage.tsx b/src/pages/notifications/NotificationsPage.tsx new file mode 100644 index 0000000..3f1ef4d --- /dev/null +++ b/src/pages/notifications/NotificationsPage.tsx @@ -0,0 +1,457 @@ +import { useState } from 'react'; +import { + Bell, + MoreVertical, + Eye, + Check, + CheckCheck, + Archive, + Trash2, + RefreshCw, + Settings, + AlertCircle, + ShoppingCart, + DollarSign, + Package, + User, + Clock, +} from 'lucide-react'; +import { Button } from '@components/atoms/Button'; +import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card'; +import { DataTable, type Column } from '@components/organisms/DataTable'; +import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown'; +import { Breadcrumbs } from '@components/organisms/Breadcrumbs'; +import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState'; +import { useInAppNotifications } from '@features/notifications/hooks'; +import type { InAppNotification, InAppCategory, NotificationPriority } from '@features/notifications/types'; +import { IN_APP_CATEGORY_LABELS, NOTIFICATION_PRIORITY_LABELS } from '@features/notifications/types'; +import { formatDate } from '@utils/formatters'; + +const categoryIcons: Record = { + system: , + order: , + payment: , + inventory: , + user: , + alert: , + reminder: , + other: , +}; + +const categoryColors: Record = { + system: 'bg-gray-100 text-gray-600', + order: 'bg-blue-100 text-blue-600', + payment: 'bg-green-100 text-green-600', + inventory: 'bg-orange-100 text-orange-600', + user: 'bg-purple-100 text-purple-600', + alert: 'bg-red-100 text-red-600', + reminder: 'bg-yellow-100 text-yellow-600', + other: 'bg-gray-100 text-gray-600', +}; + +const priorityColors: Record = { + low: 'border-l-gray-300', + normal: 'border-l-blue-400', + high: 'border-l-orange-400', + urgent: 'border-l-red-500', +}; + +export function NotificationsPage() { + const [selectedCategory, setSelectedCategory] = useState(''); + const [showUnreadOnly, setShowUnreadOnly] = useState(false); + const [selectedNotification, setSelectedNotification] = useState(null); + + const { + notifications, + unreadCount, + total, + page, + totalPages, + isLoading, + error, + setFilters, + refresh, + markAsRead, + markAllAsRead, + archive, + remove, + } = useInAppNotifications({ + initialFilters: { + includeRead: !showUnreadOnly, + category: selectedCategory || undefined, + }, + pollInterval: 30000, + }); + + const handleCategoryFilter = (category: InAppCategory | '') => { + setSelectedCategory(category); + setFilters({ + includeRead: !showUnreadOnly, + category: category || undefined, + }); + }; + + const handleUnreadFilter = (unreadOnly: boolean) => { + setShowUnreadOnly(unreadOnly); + setFilters({ + includeRead: !unreadOnly, + category: selectedCategory || undefined, + }); + }; + + const handleMarkAsRead = async (id: string) => { + await markAsRead(id); + }; + + const handleMarkAllAsRead = async () => { + await markAllAsRead(); + }; + + const handleArchive = async (id: string) => { + await archive(id); + }; + + const handleDelete = async (id: string) => { + await remove(id); + }; + + const columns: Column[] = [ + { + key: 'notification', + header: 'Notificacion', + render: (notification) => ( +
+
+ {categoryIcons[notification.category]} +
+
+
+ + {notification.title} + + {!notification.isRead && ( + + )} +
+

{notification.message}

+ {notification.actionUrl && ( + e.stopPropagation()} + > + {notification.actionLabel || 'Ver mas'} + + )} +
+
+ ), + }, + { + key: 'category', + header: 'Categoria', + render: (notification) => ( + + {categoryIcons[notification.category]} + {IN_APP_CATEGORY_LABELS[notification.category]} + + ), + }, + { + key: 'priority', + header: 'Prioridad', + render: (notification) => ( + + {NOTIFICATION_PRIORITY_LABELS[notification.priority]} + + ), + }, + { + key: 'createdAt', + header: 'Fecha', + render: (notification) => ( + + {formatDate(notification.createdAt, 'short')} + + ), + }, + { + key: 'actions', + header: '', + render: (notification) => { + const items: DropdownItem[] = [ + { + key: 'view', + label: 'Ver detalle', + icon: , + onClick: () => setSelectedNotification(notification), + }, + ]; + + if (!notification.isRead) { + items.push({ + key: 'markRead', + label: 'Marcar como leida', + icon: , + onClick: () => handleMarkAsRead(notification.id), + }); + } + + items.push( + { + key: 'archive', + label: 'Archivar', + icon: , + onClick: () => handleArchive(notification.id), + }, + { + key: 'delete', + label: 'Eliminar', + icon: , + danger: true, + onClick: () => handleDelete(notification.id), + } + ); + + return ( + + + + } + items={items} + align="right" + /> + ); + }, + }, + ]; + + // Count by category + const categoryCounts = notifications.reduce((acc, n) => { + acc[n.category] = (acc[n.category] || 0) + 1; + return acc; + }, {} as Record); + + if (error) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Notificaciones

+

+ {unreadCount > 0 ? `Tienes ${unreadCount} notificaciones sin leer` : 'No tienes notificaciones sin leer'} +

+
+
+ {unreadCount > 0 && ( + + )} + + +
+
+ + {/* Summary Stats */} +
+ {Object.entries(IN_APP_CATEGORY_LABELS).map(([category, label]) => ( + handleCategoryFilter(selectedCategory === category ? '' : category as InAppCategory)} + > + +
+
+ {categoryIcons[category as InAppCategory]} +
+
{label}
+
{categoryCounts[category] || 0}
+
+
+
+ ))} +
+ + + +
+ + + {showUnreadOnly ? 'Notificaciones sin leer' : 'Todas las notificaciones'} + {unreadCount > 0 && ( + + {unreadCount} + + )} + +
+
+ +
+ {/* Filters */} +
+ + + + + {(showUnreadOnly || selectedCategory) && ( + + )} +
+ + {/* Table */} + {notifications.length === 0 && !isLoading ? ( + + ) : ( + setFilters({ page: p }), + }} + /> + )} +
+
+
+ + {/* Notification Detail Modal */} + {selectedNotification && ( +
+ + +
+ +
+ {categoryIcons[selectedNotification.category]} +
+ {selectedNotification.title} +
+ +
+
+ +

{selectedNotification.message}

+ +
+
+
Categoria
+ + {IN_APP_CATEGORY_LABELS[selectedNotification.category]} + +
+
+
Prioridad
+
{NOTIFICATION_PRIORITY_LABELS[selectedNotification.priority]}
+
+
+
Fecha
+
{formatDate(selectedNotification.createdAt, 'full')}
+
+
+
Estado
+
{selectedNotification.isRead ? 'Leida' : 'Sin leer'}
+
+
+ + {selectedNotification.actionUrl && ( + + {selectedNotification.actionLabel || 'Ir a la accion'} + + + )} + +
+ {!selectedNotification.isRead && ( + + )} + +
+
+
+
+ )} +
+ ); +} + +export default NotificationsPage; diff --git a/src/pages/notifications/index.ts b/src/pages/notifications/index.ts new file mode 100644 index 0000000..b741699 --- /dev/null +++ b/src/pages/notifications/index.ts @@ -0,0 +1 @@ +export { NotificationsPage } from './NotificationsPage'; diff --git a/src/pages/settings/AuditLogsPage.tsx b/src/pages/settings/AuditLogsPage.tsx new file mode 100644 index 0000000..c804c12 --- /dev/null +++ b/src/pages/settings/AuditLogsPage.tsx @@ -0,0 +1,482 @@ +import { useState } from 'react'; +import { + FileText, + MoreVertical, + Eye, + Download, + RefreshCw, + Search, + Calendar, + User, + Activity, + LogIn, + LogOut, + Upload, + Trash2, + Edit2, + Filter, + Shield, +} from 'lucide-react'; +import { Button } from '@components/atoms/Button'; +import { Card, CardHeader, CardTitle, CardContent } from '@components/molecules/Card'; +import { DataTable, type Column } from '@components/organisms/DataTable'; +import { Dropdown, type DropdownItem } from '@components/organisms/Dropdown'; +import { Breadcrumbs } from '@components/organisms/Breadcrumbs'; +import { NoDataEmptyState, ErrorEmptyState } from '@components/templates/EmptyState'; +import { useAuditLogs } from '@features/settings/hooks'; +import type { AuditLog, AuditAction } from '@features/settings/types'; +import { AUDIT_ACTION_LABELS } from '@features/settings/types'; +import { formatDate } from '@utils/formatters'; + +const actionIcons: Record = { + create: , + update: , + delete: , + login: , + logout: , + export: , + import: , +}; + +const actionColors: Record = { + create: 'bg-green-100 text-green-700', + update: 'bg-blue-100 text-blue-700', + delete: 'bg-red-100 text-red-700', + login: 'bg-purple-100 text-purple-700', + logout: 'bg-gray-100 text-gray-700', + export: 'bg-orange-100 text-orange-700', + import: 'bg-cyan-100 text-cyan-700', +}; + +export function AuditLogsPage() { + const [selectedAction, setSelectedAction] = useState(''); + const [selectedUserId] = useState(''); + const [selectedEntityType, setSelectedEntityType] = useState(''); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + const [selectedLog, setSelectedLog] = useState(null); + + const { + logs, + total, + page, + totalPages, + isLoading, + error, + filters, + setFilters, + refresh, + exportLogs, + } = useAuditLogs({ + initialFilters: { + action: selectedAction || undefined, + userId: selectedUserId || undefined, + entityType: selectedEntityType || undefined, + dateFrom: dateFrom || undefined, + dateTo: dateTo || undefined, + }, + }); + + const handleFilterChange = () => { + setFilters({ + action: selectedAction || undefined, + userId: selectedUserId || undefined, + entityType: selectedEntityType || undefined, + dateFrom: dateFrom || undefined, + dateTo: dateTo || undefined, + }); + }; + + const handleExport = async () => { + try { + await exportLogs(); + } catch (err) { + console.error('Export failed:', err); + } + }; + + const columns: Column[] = [ + { + key: 'action', + header: 'Accion', + render: (log) => ( +
+ + {actionIcons[log.action]} + {AUDIT_ACTION_LABELS[log.action]} + +
+ ), + }, + { + key: 'user', + header: 'Usuario', + render: (log) => ( +
+
+ +
+ {log.userName || log.userId} +
+ ), + }, + { + key: 'entityType', + header: 'Entidad', + render: (log) => ( +
+
{log.entityType}
+
{log.entityId}
+
+ ), + }, + { + key: 'createdAt', + header: 'Fecha', + render: (log) => ( + + {formatDate(log.createdAt, 'full')} + + ), + }, + { + key: 'ipAddress', + header: 'IP', + render: (log) => ( + + {log.ipAddress || '-'} + + ), + }, + { + key: 'actions', + header: '', + render: (log) => { + const items: DropdownItem[] = [ + { + key: 'view', + label: 'Ver detalles', + icon: , + onClick: () => setSelectedLog(log), + }, + ]; + + return ( + + + + } + items={items} + align="right" + /> + ); + }, + }, + ]; + + // Stats + const loginCount = logs.filter(l => l.action === 'login').length; + const createCount = logs.filter(l => l.action === 'create').length; + const updateCount = logs.filter(l => l.action === 'update').length; + const deleteCount = logs.filter(l => l.action === 'delete').length; + + // Get unique entity types for filter + const entityTypes = [...new Set(logs.map(l => l.entityType))]; + + if (error) { + return ( +
+ +
+ ); + } + + return ( +
+ + +
+
+

Logs de Auditoria

+

+ Registro de todas las actividades del sistema +

+
+
+ + +
+
+ + {/* Summary Stats */} +
+ setSelectedAction('login')}> + +
+
+ +
+
+
Inicios de sesion
+
{loginCount}
+
+
+
+
+ + setSelectedAction('create')}> + +
+
+ +
+
+
Creaciones
+
{createCount}
+
+
+
+
+ + setSelectedAction('update')}> + +
+
+ +
+
+
Actualizaciones
+
{updateCount}
+
+
+
+
+ + setSelectedAction('delete')}> + +
+
+ +
+
+
Eliminaciones
+
{deleteCount}
+
+
+
+
+
+ + + + + + Historial de Actividad + + + +
+ {/* Filters */} +
+
+ + +
+ +
+ + +
+ +
+ + setDateFrom(e.target.value)} + className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ +
+ + setDateTo(e.target.value)} + className="rounded-md border border-gray-300 px-3 py-2 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + /> +
+ + + + {(selectedAction || selectedEntityType || dateFrom || dateTo) && ( + + )} +
+ + {/* Table */} + {logs.length === 0 && !isLoading ? ( + + ) : ( + setFilters({ ...filters, page: p }), + }} + /> + )} +
+
+
+ + {/* Log Detail Modal */} + {selectedLog && ( +
+ + +
+ + + Detalle del Registro + + +
+
+ +
+
+
Accion
+ + {actionIcons[selectedLog.action]} + {AUDIT_ACTION_LABELS[selectedLog.action]} + +
+
+
Usuario
+
{selectedLog.userName || selectedLog.userId}
+
+
+
Entidad
+
{selectedLog.entityType}
+
+
+
ID de Entidad
+
{selectedLog.entityId}
+
+
+
Fecha
+
{formatDate(selectedLog.createdAt, 'full')}
+
+
+
IP
+
{selectedLog.ipAddress || '-'}
+
+
+ + {selectedLog.oldValues && ( +
+
Valores anteriores
+
+                    {JSON.stringify(selectedLog.oldValues, null, 2)}
+                  
+
+ )} + + {selectedLog.newValues && ( +
+
Valores nuevos
+
+                    {JSON.stringify(selectedLog.newValues, null, 2)}
+                  
+
+ )} + + {selectedLog.userAgent && ( +
+
User Agent
+
{selectedLog.userAgent}
+
+ )} + +
+ +
+
+
+
+ )} +
+ ); +} + +export default AuditLogsPage; diff --git a/src/pages/settings/index.ts b/src/pages/settings/index.ts index 806268e..520f969 100644 --- a/src/pages/settings/index.ts +++ b/src/pages/settings/index.ts @@ -1,2 +1,3 @@ export { SettingsPage } from './SettingsPage'; export { UsersSettingsPage } from './UsersSettingsPage'; +export { AuditLogsPage } from './AuditLogsPage';