feat(frontend): Add Audit and Notifications modules (MGN-007, MGN-008)

Audit module (MGN-007):
- AuditLogsPage.tsx: Full audit logs UI with filtering, stats cards, detail modal
- Uses existing types, api, hooks from settings feature

Notifications module (MGN-008):
- types/notifications.types.ts: Complete types for channels, templates, preferences, in-app
- api/notifications.api.ts: API clients for all notification operations
- hooks/useNotifications.ts: 5 hooks (useChannels, useTemplates, useNotificationPreferences,
  useInAppNotifications, useNotificationBell)
- NotificationsPage.tsx: Full notifications center with category cards, filtering,
  read/unread management

Both modules complement complete backends with comprehensive frontend UIs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-18 11:20:25 -06:00
parent 4eb8ee2699
commit 6b7f438669
11 changed files with 1757 additions and 0 deletions

View File

@ -0,0 +1 @@
export * from './notifications.api';

View File

@ -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<NotificationChannel[]> => {
const response = await api.get<NotificationChannel[]>(`${NOTIFICATIONS_BASE}/channels`);
return response.data;
},
getByCode: async (code: string): Promise<NotificationChannel> => {
const response = await api.get<NotificationChannel>(`${NOTIFICATIONS_BASE}/channels/${code}`);
return response.data;
},
};
// ============================================================================
// Templates API
// ============================================================================
export const templatesApi = {
getAll: async (): Promise<NotificationTemplate[]> => {
const response = await api.get<NotificationTemplate[]>(`${NOTIFICATIONS_BASE}/templates`);
return response.data;
},
getByCode: async (code: string): Promise<NotificationTemplate> => {
const response = await api.get<NotificationTemplate>(`${NOTIFICATIONS_BASE}/templates/${code}`);
return response.data;
},
create: async (data: NotificationTemplateCreateInput): Promise<NotificationTemplate> => {
const response = await api.post<NotificationTemplate>(`${NOTIFICATIONS_BASE}/templates`, data);
return response.data;
},
update: async (id: string, data: Partial<NotificationTemplateCreateInput>): Promise<NotificationTemplate> => {
const response = await api.patch<NotificationTemplate>(`${NOTIFICATIONS_BASE}/templates/${id}`, data);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`${NOTIFICATIONS_BASE}/templates/${id}`);
},
};
// ============================================================================
// Preferences API
// ============================================================================
export const preferencesApi = {
get: async (): Promise<NotificationPreference> => {
const response = await api.get<NotificationPreference>(`${NOTIFICATIONS_BASE}/preferences`);
return response.data;
},
update: async (data: NotificationPreferenceUpdateInput): Promise<NotificationPreference> => {
const response = await api.patch<NotificationPreference>(`${NOTIFICATIONS_BASE}/preferences`, data);
return response.data;
},
};
// ============================================================================
// Notifications API
// ============================================================================
export const notificationsApi = {
create: async (data: NotificationCreateInput): Promise<Notification> => {
const response = await api.post<Notification>(`${NOTIFICATIONS_BASE}`, data);
return response.data;
},
getPending: async (limit = 50): Promise<Notification[]> => {
const response = await api.get<Notification[]>(`${NOTIFICATIONS_BASE}/pending?limit=${limit}`);
return response.data;
},
updateStatus: async (id: string, status: NotificationStatus, errorMessage?: string): Promise<Notification> => {
const response = await api.patch<Notification>(`${NOTIFICATIONS_BASE}/${id}/status`, {
status,
errorMessage,
});
return response.data;
},
};
// ============================================================================
// In-App Notifications API
// ============================================================================
export const inAppApi = {
getAll: async (filters: InAppNotificationsFilters = {}): Promise<InAppNotificationsResponse> => {
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<InAppNotificationsResponse>(`${NOTIFICATIONS_BASE}/in-app?${params}`);
return response.data;
},
getUnreadCount: async (): Promise<number> => {
const response = await api.get<{ count: number }>(`${NOTIFICATIONS_BASE}/in-app/unread-count`);
return response.data.count;
},
markAsRead: async (id: string): Promise<InAppNotification> => {
const response = await api.post<InAppNotification>(`${NOTIFICATIONS_BASE}/in-app/${id}/read`);
return response.data;
},
markAllAsRead: async (): Promise<void> => {
await api.post(`${NOTIFICATIONS_BASE}/in-app/read-all`);
},
create: async (data: InAppNotificationCreateInput): Promise<InAppNotification> => {
const response = await api.post<InAppNotification>(`${NOTIFICATIONS_BASE}/in-app`, data);
return response.data;
},
archive: async (id: string): Promise<InAppNotification> => {
const response = await api.post<InAppNotification>(`${NOTIFICATIONS_BASE}/in-app/${id}/archive`);
return response.data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`${NOTIFICATIONS_BASE}/in-app/${id}`);
},
};

View File

@ -0,0 +1,7 @@
export {
useChannels,
useTemplates,
useNotificationPreferences,
useInAppNotifications,
useNotificationBell,
} from './useNotifications';

View File

@ -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<void>;
getByCode: (code: string) => Promise<NotificationChannel>;
}
export function useChannels(): UseChannelsReturn {
const [channels, setChannels] = useState<NotificationChannel[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(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<NotificationChannel> => {
return channelsApi.getByCode(code);
}, []);
return {
channels,
isLoading,
error,
refresh: fetchChannels,
getByCode,
};
}
// ============================================================================
// Templates Hook
// ============================================================================
interface UseTemplatesReturn {
templates: NotificationTemplate[];
isLoading: boolean;
error: Error | null;
refresh: () => Promise<void>;
getByCode: (code: string) => Promise<NotificationTemplate>;
create: (data: NotificationTemplateCreateInput) => Promise<NotificationTemplate>;
update: (id: string, data: Partial<NotificationTemplateCreateInput>) => Promise<NotificationTemplate>;
remove: (id: string) => Promise<void>;
}
export function useTemplates(): UseTemplatesReturn {
const [templates, setTemplates] = useState<NotificationTemplate[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(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<NotificationTemplate> => {
return templatesApi.getByCode(code);
}, []);
const create = useCallback(async (data: NotificationTemplateCreateInput): Promise<NotificationTemplate> => {
const result = await templatesApi.create(data);
await fetchTemplates();
return result;
}, [fetchTemplates]);
const update = useCallback(async (id: string, data: Partial<NotificationTemplateCreateInput>): Promise<NotificationTemplate> => {
const result = await templatesApi.update(id, data);
await fetchTemplates();
return result;
}, [fetchTemplates]);
const remove = useCallback(async (id: string): Promise<void> => {
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<void>;
update: (data: NotificationPreferenceUpdateInput) => Promise<NotificationPreference>;
}
export function useNotificationPreferences(): UseNotificationPreferencesReturn {
const [preferences, setPreferences] = useState<NotificationPreference | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<Error | null>(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<NotificationPreference> => {
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<void>;
markAsRead: (id: string) => Promise<void>;
markAllAsRead: () => Promise<void>;
archive: (id: string) => Promise<void>;
remove: (id: string) => Promise<void>;
}
export function useInAppNotifications(
options: UseInAppNotificationsOptions = {}
): UseInAppNotificationsReturn {
const { initialFilters = {}, autoLoad = true, pollInterval } = options;
const [notifications, setNotifications] = useState<InAppNotification[]>([]);
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<Error | null>(null);
const [filters, setFilters] = useState<InAppNotificationsFilters>(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<void> => {
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<void> => {
await inAppApi.markAllAsRead();
setNotifications(prev =>
prev.map(n => ({ ...n, isRead: true, readAt: new Date().toISOString() }))
);
setUnreadCount(0);
}, []);
const archive = useCallback(async (id: string): Promise<void> => {
await inAppApi.archive(id);
setNotifications(prev => prev.filter(n => n.id !== id));
}, []);
const remove = useCallback(async (id: string): Promise<void> => {
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<void>;
markAsRead: (id: string) => Promise<void>;
markAllAsRead: () => Promise<void>;
}
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,
};
}

View File

@ -0,0 +1,10 @@
// Notifications Feature - Barrel Export
// Types
export * from './types';
// API
export * from './api';
// Hooks
export * from './hooks';

View File

@ -0,0 +1 @@
export * from './notifications.types';

View File

@ -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<string, any>;
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<NotificationTemplateCreateInput> {
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<string, boolean>;
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<string, boolean>;
}
// ============================================================================
// 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<string, any>;
createdAt: string;
updatedAt: string;
}
export interface NotificationCreateInput {
userId: string;
templateCode?: string;
channelType: ChannelType;
recipient: string;
subject?: string;
body?: string;
variables?: Record<string, any>;
priority?: NotificationPriority;
scheduledAt?: string;
metadata?: Record<string, any>;
}
// ============================================================================
// 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<string, any>;
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<string, any>;
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<string, any>;
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<ChannelType, string> = {
email: 'Correo electronico',
sms: 'SMS',
push: 'Notificacion push',
whatsapp: 'WhatsApp',
in_app: 'En la aplicacion',
webhook: 'Webhook',
};
export const NOTIFICATION_STATUS_LABELS: Record<NotificationStatus, string> = {
pending: 'Pendiente',
queued: 'En cola',
sending: 'Enviando',
sent: 'Enviado',
delivered: 'Entregado',
read: 'Leido',
failed: 'Fallido',
cancelled: 'Cancelado',
};
export const NOTIFICATION_PRIORITY_LABELS: Record<NotificationPriority, string> = {
low: 'Baja',
normal: 'Normal',
high: 'Alta',
urgent: 'Urgente',
};
export const IN_APP_CATEGORY_LABELS: Record<InAppCategory, string> = {
system: 'Sistema',
order: 'Pedidos',
payment: 'Pagos',
inventory: 'Inventario',
user: 'Usuarios',
alert: 'Alertas',
reminder: 'Recordatorios',
other: 'Otros',
};
export const BATCH_STATUS_LABELS: Record<BatchStatus, string> = {
draft: 'Borrador',
scheduled: 'Programado',
sending: 'Enviando',
completed: 'Completado',
failed: 'Fallido',
cancelled: 'Cancelado',
};

View File

@ -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<InAppCategory, React.ReactNode> = {
system: <Settings className="h-4 w-4" />,
order: <ShoppingCart className="h-4 w-4" />,
payment: <DollarSign className="h-4 w-4" />,
inventory: <Package className="h-4 w-4" />,
user: <User className="h-4 w-4" />,
alert: <AlertCircle className="h-4 w-4" />,
reminder: <Clock className="h-4 w-4" />,
other: <Bell className="h-4 w-4" />,
};
const categoryColors: Record<InAppCategory, string> = {
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<NotificationPriority, string> = {
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<InAppCategory | ''>('');
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
const [selectedNotification, setSelectedNotification] = useState<InAppNotification | null>(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<InAppNotification>[] = [
{
key: 'notification',
header: 'Notificacion',
render: (notification) => (
<div
className={`flex items-start gap-3 pl-3 border-l-4 ${priorityColors[notification.priority]} ${
!notification.isRead ? 'bg-blue-50' : ''
}`}
>
<div className={`flex h-10 w-10 items-center justify-center rounded-full ${categoryColors[notification.category]}`}>
{categoryIcons[notification.category]}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`font-medium ${!notification.isRead ? 'text-gray-900' : 'text-gray-700'}`}>
{notification.title}
</span>
{!notification.isRead && (
<span className="h-2 w-2 rounded-full bg-blue-500" />
)}
</div>
<p className="text-sm text-gray-600 truncate">{notification.message}</p>
{notification.actionUrl && (
<a
href={notification.actionUrl}
className="text-xs text-blue-600 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{notification.actionLabel || 'Ver mas'}
</a>
)}
</div>
</div>
),
},
{
key: 'category',
header: 'Categoria',
render: (notification) => (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${categoryColors[notification.category]}`}>
{categoryIcons[notification.category]}
{IN_APP_CATEGORY_LABELS[notification.category]}
</span>
),
},
{
key: 'priority',
header: 'Prioridad',
render: (notification) => (
<span className="text-sm text-gray-600">
{NOTIFICATION_PRIORITY_LABELS[notification.priority]}
</span>
),
},
{
key: 'createdAt',
header: 'Fecha',
render: (notification) => (
<span className="text-sm text-gray-500">
{formatDate(notification.createdAt, 'short')}
</span>
),
},
{
key: 'actions',
header: '',
render: (notification) => {
const items: DropdownItem[] = [
{
key: 'view',
label: 'Ver detalle',
icon: <Eye className="h-4 w-4" />,
onClick: () => setSelectedNotification(notification),
},
];
if (!notification.isRead) {
items.push({
key: 'markRead',
label: 'Marcar como leida',
icon: <Check className="h-4 w-4" />,
onClick: () => handleMarkAsRead(notification.id),
});
}
items.push(
{
key: 'archive',
label: 'Archivar',
icon: <Archive className="h-4 w-4" />,
onClick: () => handleArchive(notification.id),
},
{
key: 'delete',
label: 'Eliminar',
icon: <Trash2 className="h-4 w-4" />,
danger: true,
onClick: () => handleDelete(notification.id),
}
);
return (
<Dropdown
trigger={
<button className="rounded p-1 hover:bg-gray-100">
<MoreVertical className="h-4 w-4 text-gray-500" />
</button>
}
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<string, number>);
if (error) {
return (
<div className="p-6">
<ErrorEmptyState onRetry={refresh} />
</div>
);
}
return (
<div className="space-y-6 p-6">
<Breadcrumbs items={[
{ label: 'Notificaciones' },
]} />
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Notificaciones</h1>
<p className="text-sm text-gray-500">
{unreadCount > 0 ? `Tienes ${unreadCount} notificaciones sin leer` : 'No tienes notificaciones sin leer'}
</p>
</div>
<div className="flex gap-2">
{unreadCount > 0 && (
<Button variant="outline" onClick={handleMarkAllAsRead}>
<CheckCheck className="mr-2 h-4 w-4" />
Marcar todas como leidas
</Button>
)}
<Button variant="outline" onClick={refresh} disabled={isLoading}>
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
<Button variant="outline" onClick={() => window.location.href = '/settings/notifications'}>
<Settings className="mr-2 h-4 w-4" />
Preferencias
</Button>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-8">
{Object.entries(IN_APP_CATEGORY_LABELS).map(([category, label]) => (
<Card
key={category}
className={`cursor-pointer transition-shadow hover:shadow-md ${
selectedCategory === category ? 'ring-2 ring-blue-500' : ''
}`}
onClick={() => handleCategoryFilter(selectedCategory === category ? '' : category as InAppCategory)}
>
<CardContent className="p-3">
<div className="flex flex-col items-center text-center">
<div className={`flex h-8 w-8 items-center justify-center rounded-full ${categoryColors[category as InAppCategory]}`}>
{categoryIcons[category as InAppCategory]}
</div>
<div className="mt-1 text-xs text-gray-500">{label}</div>
<div className="font-bold">{categoryCounts[category] || 0}</div>
</div>
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" />
{showUnreadOnly ? 'Notificaciones sin leer' : 'Todas las notificaciones'}
{unreadCount > 0 && (
<span className="ml-2 rounded-full bg-blue-500 px-2 py-0.5 text-xs text-white">
{unreadCount}
</span>
)}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={showUnreadOnly}
onChange={(e) => handleUnreadFilter(e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">Solo sin leer</span>
</label>
<select
value={selectedCategory}
onChange={(e) => handleCategoryFilter(e.target.value as InAppCategory | '')}
className="rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Todas las categorias</option>
{Object.entries(IN_APP_CATEGORY_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
{(showUnreadOnly || selectedCategory) && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowUnreadOnly(false);
setSelectedCategory('');
setFilters({ includeRead: true });
}}
>
Limpiar filtros
</Button>
)}
</div>
{/* Table */}
{notifications.length === 0 && !isLoading ? (
<NoDataEmptyState entityName="notificaciones" />
) : (
<DataTable
data={notifications}
columns={columns}
isLoading={isLoading}
pagination={{
page,
totalPages,
total,
limit: 20,
onPageChange: (p) => setFilters({ page: p }),
}}
/>
)}
</div>
</CardContent>
</Card>
{/* Notification Detail Modal */}
{selectedNotification && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<Card className="w-full max-w-lg">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<div className={`flex h-8 w-8 items-center justify-center rounded-full ${categoryColors[selectedNotification.category]}`}>
{categoryIcons[selectedNotification.category]}
</div>
{selectedNotification.title}
</CardTitle>
<button
onClick={() => setSelectedNotification(null)}
className="text-gray-400 hover:text-gray-600 text-2xl"
>
&times;
</button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-gray-700">{selectedNotification.message}</p>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<div className="text-gray-500">Categoria</div>
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${categoryColors[selectedNotification.category]}`}>
{IN_APP_CATEGORY_LABELS[selectedNotification.category]}
</span>
</div>
<div>
<div className="text-gray-500">Prioridad</div>
<div>{NOTIFICATION_PRIORITY_LABELS[selectedNotification.priority]}</div>
</div>
<div>
<div className="text-gray-500">Fecha</div>
<div>{formatDate(selectedNotification.createdAt, 'full')}</div>
</div>
<div>
<div className="text-gray-500">Estado</div>
<div>{selectedNotification.isRead ? 'Leida' : 'Sin leer'}</div>
</div>
</div>
{selectedNotification.actionUrl && (
<a
href={selectedNotification.actionUrl}
className="inline-flex items-center gap-2 text-blue-600 hover:underline"
>
{selectedNotification.actionLabel || 'Ir a la accion'}
<Eye className="h-4 w-4" />
</a>
)}
<div className="flex justify-end gap-2 pt-4 border-t">
{!selectedNotification.isRead && (
<Button
variant="outline"
onClick={() => {
handleMarkAsRead(selectedNotification.id);
setSelectedNotification(null);
}}
>
<Check className="mr-2 h-4 w-4" />
Marcar como leida
</Button>
)}
<Button variant="outline" onClick={() => setSelectedNotification(null)}>
Cerrar
</Button>
</div>
</CardContent>
</Card>
</div>
)}
</div>
);
}
export default NotificationsPage;

View File

@ -0,0 +1 @@
export { NotificationsPage } from './NotificationsPage';

View File

@ -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<AuditAction, React.ReactNode> = {
create: <Edit2 className="h-4 w-4 text-green-600" />,
update: <Edit2 className="h-4 w-4 text-blue-600" />,
delete: <Trash2 className="h-4 w-4 text-red-600" />,
login: <LogIn className="h-4 w-4 text-purple-600" />,
logout: <LogOut className="h-4 w-4 text-gray-600" />,
export: <Download className="h-4 w-4 text-orange-600" />,
import: <Upload className="h-4 w-4 text-cyan-600" />,
};
const actionColors: Record<AuditAction, string> = {
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<AuditAction | ''>('');
const [selectedUserId] = useState('');
const [selectedEntityType, setSelectedEntityType] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(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<AuditLog>[] = [
{
key: 'action',
header: 'Accion',
render: (log) => (
<div className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${actionColors[log.action]}`}>
{actionIcons[log.action]}
{AUDIT_ACTION_LABELS[log.action]}
</span>
</div>
),
},
{
key: 'user',
header: 'Usuario',
render: (log) => (
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-100">
<User className="h-4 w-4 text-gray-600" />
</div>
<span className="text-sm text-gray-900">{log.userName || log.userId}</span>
</div>
),
},
{
key: 'entityType',
header: 'Entidad',
render: (log) => (
<div>
<div className="text-sm font-medium text-gray-900">{log.entityType}</div>
<div className="text-xs text-gray-500 font-mono">{log.entityId}</div>
</div>
),
},
{
key: 'createdAt',
header: 'Fecha',
render: (log) => (
<span className="text-sm text-gray-600">
{formatDate(log.createdAt, 'full')}
</span>
),
},
{
key: 'ipAddress',
header: 'IP',
render: (log) => (
<span className="text-sm text-gray-500 font-mono">
{log.ipAddress || '-'}
</span>
),
},
{
key: 'actions',
header: '',
render: (log) => {
const items: DropdownItem[] = [
{
key: 'view',
label: 'Ver detalles',
icon: <Eye className="h-4 w-4" />,
onClick: () => setSelectedLog(log),
},
];
return (
<Dropdown
trigger={
<button className="rounded p-1 hover:bg-gray-100">
<MoreVertical className="h-4 w-4 text-gray-500" />
</button>
}
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 (
<div className="p-6">
<ErrorEmptyState onRetry={refresh} />
</div>
);
}
return (
<div className="space-y-6 p-6">
<Breadcrumbs items={[
{ label: 'Configuracion', href: '/settings' },
{ label: 'Seguridad', href: '/settings/security' },
{ label: 'Logs de Auditoria' },
]} />
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Logs de Auditoria</h1>
<p className="text-sm text-gray-500">
Registro de todas las actividades del sistema
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleExport}>
<Download className="mr-2 h-4 w-4" />
Exportar
</Button>
<Button variant="outline" onClick={refresh} disabled={isLoading}>
<RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
Actualizar
</Button>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedAction('login')}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-100">
<LogIn className="h-5 w-5 text-purple-600" />
</div>
<div>
<div className="text-sm text-gray-500">Inicios de sesion</div>
<div className="text-xl font-bold text-purple-600">{loginCount}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedAction('create')}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
<Edit2 className="h-5 w-5 text-green-600" />
</div>
<div>
<div className="text-sm text-gray-500">Creaciones</div>
<div className="text-xl font-bold text-green-600">{createCount}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedAction('update')}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
<Activity className="h-5 w-5 text-blue-600" />
</div>
<div>
<div className="text-sm text-gray-500">Actualizaciones</div>
<div className="text-xl font-bold text-blue-600">{updateCount}</div>
</div>
</div>
</CardContent>
</Card>
<Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedAction('delete')}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-100">
<Trash2 className="h-5 w-5 text-red-600" />
</div>
<div>
<div className="text-sm text-gray-500">Eliminaciones</div>
<div className="text-xl font-bold text-red-600">{deleteCount}</div>
</div>
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Historial de Actividad
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-wrap items-end gap-4 p-4 bg-gray-50 rounded-lg">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<Filter className="inline-block h-4 w-4 mr-1" />
Accion
</label>
<select
value={selectedAction}
onChange={(e) => {
setSelectedAction(e.target.value as AuditAction | '');
}}
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"
>
<option value="">Todas las acciones</option>
{Object.entries(AUDIT_ACTION_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tipo de entidad
</label>
<select
value={selectedEntityType}
onChange={(e) => setSelectedEntityType(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"
>
<option value="">Todas las entidades</option>
{entityTypes.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<Calendar className="inline-block h-4 w-4 mr-1" />
Desde
</label>
<input
type="date"
value={dateFrom}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Hasta
</label>
<input
type="date"
value={dateTo}
onChange={(e) => 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"
/>
</div>
<Button onClick={handleFilterChange}>
<Search className="mr-2 h-4 w-4" />
Buscar
</Button>
{(selectedAction || selectedEntityType || dateFrom || dateTo) && (
<Button
variant="ghost"
onClick={() => {
setSelectedAction('');
setSelectedEntityType('');
setDateFrom('');
setDateTo('');
setFilters({});
}}
>
Limpiar filtros
</Button>
)}
</div>
{/* Table */}
{logs.length === 0 && !isLoading ? (
<NoDataEmptyState entityName="registros de auditoria" />
) : (
<DataTable
data={logs}
columns={columns}
isLoading={isLoading}
pagination={{
page,
totalPages,
total,
limit: 20,
onPageChange: (p) => setFilters({ ...filters, page: p }),
}}
/>
)}
</div>
</CardContent>
</Card>
{/* Log Detail Modal */}
{selectedLog && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<Card className="w-full max-w-2xl max-h-[80vh] overflow-auto">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Detalle del Registro
</CardTitle>
<button
onClick={() => setSelectedLog(null)}
className="text-gray-400 hover:text-gray-600"
>
&times;
</button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm text-gray-500">Accion</div>
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ${actionColors[selectedLog.action]}`}>
{actionIcons[selectedLog.action]}
{AUDIT_ACTION_LABELS[selectedLog.action]}
</span>
</div>
<div>
<div className="text-sm text-gray-500">Usuario</div>
<div className="font-medium">{selectedLog.userName || selectedLog.userId}</div>
</div>
<div>
<div className="text-sm text-gray-500">Entidad</div>
<div className="font-medium">{selectedLog.entityType}</div>
</div>
<div>
<div className="text-sm text-gray-500">ID de Entidad</div>
<div className="font-mono text-sm">{selectedLog.entityId}</div>
</div>
<div>
<div className="text-sm text-gray-500">Fecha</div>
<div>{formatDate(selectedLog.createdAt, 'full')}</div>
</div>
<div>
<div className="text-sm text-gray-500">IP</div>
<div className="font-mono text-sm">{selectedLog.ipAddress || '-'}</div>
</div>
</div>
{selectedLog.oldValues && (
<div>
<div className="text-sm font-medium text-gray-700 mb-2">Valores anteriores</div>
<pre className="bg-gray-100 p-3 rounded text-xs overflow-auto max-h-40">
{JSON.stringify(selectedLog.oldValues, null, 2)}
</pre>
</div>
)}
{selectedLog.newValues && (
<div>
<div className="text-sm font-medium text-gray-700 mb-2">Valores nuevos</div>
<pre className="bg-gray-100 p-3 rounded text-xs overflow-auto max-h-40">
{JSON.stringify(selectedLog.newValues, null, 2)}
</pre>
</div>
)}
{selectedLog.userAgent && (
<div>
<div className="text-sm text-gray-500">User Agent</div>
<div className="text-xs text-gray-600 break-all">{selectedLog.userAgent}</div>
</div>
)}
<div className="flex justify-end pt-4 border-t">
<Button variant="outline" onClick={() => setSelectedLog(null)}>
Cerrar
</Button>
</div>
</CardContent>
</Card>
</div>
)}
</div>
);
}
export default AuditLogsPage;

View File

@ -1,2 +1,3 @@
export { SettingsPage } from './SettingsPage';
export { UsersSettingsPage } from './UsersSettingsPage';
export { AuditLogsPage } from './AuditLogsPage';