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:
parent
4eb8ee2699
commit
6b7f438669
1
src/features/notifications/api/index.ts
Normal file
1
src/features/notifications/api/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './notifications.api';
|
||||||
148
src/features/notifications/api/notifications.api.ts
Normal file
148
src/features/notifications/api/notifications.api.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
7
src/features/notifications/hooks/index.ts
Normal file
7
src/features/notifications/hooks/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export {
|
||||||
|
useChannels,
|
||||||
|
useTemplates,
|
||||||
|
useNotificationPreferences,
|
||||||
|
useInAppNotifications,
|
||||||
|
useNotificationBell,
|
||||||
|
} from './useNotifications';
|
||||||
356
src/features/notifications/hooks/useNotifications.ts
Normal file
356
src/features/notifications/hooks/useNotifications.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
10
src/features/notifications/index.ts
Normal file
10
src/features/notifications/index.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
// Notifications Feature - Barrel Export
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export * from './types';
|
||||||
|
|
||||||
|
// API
|
||||||
|
export * from './api';
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
export * from './hooks';
|
||||||
1
src/features/notifications/types/index.ts
Normal file
1
src/features/notifications/types/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './notifications.types';
|
||||||
293
src/features/notifications/types/notifications.types.ts
Normal file
293
src/features/notifications/types/notifications.types.ts
Normal 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',
|
||||||
|
};
|
||||||
457
src/pages/notifications/NotificationsPage.tsx
Normal file
457
src/pages/notifications/NotificationsPage.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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;
|
||||||
1
src/pages/notifications/index.ts
Normal file
1
src/pages/notifications/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { NotificationsPage } from './NotificationsPage';
|
||||||
482
src/pages/settings/AuditLogsPage.tsx
Normal file
482
src/pages/settings/AuditLogsPage.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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;
|
||||||
@ -1,2 +1,3 @@
|
|||||||
export { SettingsPage } from './SettingsPage';
|
export { SettingsPage } from './SettingsPage';
|
||||||
export { UsersSettingsPage } from './UsersSettingsPage';
|
export { UsersSettingsPage } from './UsersSettingsPage';
|
||||||
|
export { AuditLogsPage } from './AuditLogsPage';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user