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 { UsersSettingsPage } from './UsersSettingsPage';
|
||||
export { AuditLogsPage } from './AuditLogsPage';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user